从0打造编码Agent 岔路 阅读 + 实验 ≈ 25 分钟 页内实验 ×1
A8 模型接入:换模型、控成本怎么办? 岔路课

出错、限流、变慢,怎么定位?

出错、限流、变慢怎么定位
T5 那次运行的墙钟 6.8 秒,只看这个数什么也看不出。按 OpenTelemetry GenAI 约定把它拆成 span:模型调用占了几乎全部时间,工具只有几十毫秒;第 8、9 轮的首帧要等 1 秒,是模型在写一个随后被服务端丢掉的调用。出错时谁来重试也要分层:Codex 的传输层不重试 429、只按 200 ms 起步的指数退避重试 5xx 与断线,流断了另有一层重连;CoreCoder 在传输层按 2 的幂重试 429。Codex 的 Retry-After 是用正则从错误文字里刮出来的。
❓

本课一问:agent 跑慢了、报错了、被限流了,先看哪里?谁来重试、隔多久重试?

这一课先不碰:流式拼装本身(第 2 课);上下文太长导致的变慢(A5);首 token 时间的机制(LLM 课 C13 第 5 课)。

从已有出发

第 2 课在 T5 第 7 轮的时间轴上看到一段 886 ms 的静默。这一课把整次运行的 9 轮一起摊开。

从已有出发 · 累计数据流 点已学环节看它回答什么
每一轮循环都经过

先自己设计:看总耗时;出错就原地重试

  1. ① 朴素方案
  2. ② 它在哪失败
  3. ③ 改进方案
朴素方案看一次运行的总耗时;出错(含 429)就在原地重试

最直接的监控是一个数:这次运行花了多久。最直接的容错是一个循环:请求失败就睡一会儿再发,不管失败的原因是网络断了、服务端 5xx,还是 429 限流。

拆开时间:一次运行的 span 瀑布

span 瀑布:这一次运行的时间花在哪

span 由项目 trace 现算(T5,Qwen/Qwen3-8B-MLX-4bit · 本机 mlx_lm.server · 2026-09-26);属性名按 OTel GenAI 约定(Development,仍会变)。耗时受本机同时运行的其他负载影响,只能粗看;token 与轮数可比。

🎯

试三件事:① 点第 7 轮的 chat span:首帧 134 ms 就到(先流出一段正文),之后有一段红色斜纹——帧与帧之间的长静默,模型在写一个工具调用,服务端解析失败把它丢了;② 看第 8、9 轮:没有正文,首帧前的斜纹占了整根条,因为本机服务要等调用写完才发帧——而那个调用又被丢了;③ 找紫色菱形:harness 两次注入提醒「你的调用解析失败被丢掉了」,两次都没救回来(A1 第 3 课的「提醒重发」)。

span 的名字与属性按 OpenTelemetry 的 GenAI 语义约定写:推理 span 叫 chat default_model,工具 span 叫 execute_tool read_file,属性里的 gen_ai.response.time_to_first_chunk 就是首帧时间(秒)。这套约定的状态仍是 Development,属性名还会变 。注意它的 gen_ai.usage.input_tokens 要求包含缓存——和第 1 课看到的 Anthropic 口径相反。

拆开重试:三层,三种原因

层Codex(pin 8cabf5a)CoreCoder v0.7.0本课项目 miniagent
传输层:这一次 HTTP 没成最多重试 4 次,200 ms 起步指数退避 × 0.9–1.1 抖动;重试 5xx 与断线,429 不重试(retry_429: false)最多 3 次,等 1 s、2 s;429 也重试不重试;429 抛 RateLimitError(带 retry_after)
流这一层:流断在半路优先用服务端给的延迟,没有就退避;重试用尽后从 WebSocket 降级到 HTTPS流中途断了整条重发(此时还没执行任何工具)不处理
上层:限流、额度429 交给 turn 这一层按额度处理——loop 以 model_error 停,由调用方决定何时再跑

OpenCode 把重试次数原样交给 AI SDK:maxRetries: input.retries ?? 0(session/llm.ts:323),不传就是 0;更上层怎么处理,本课没有往下追。

Codex 传输层:哪些错误重试、隔多久 codex@8cabf5a:codex-rs/codex-client/src/retry.rs:8-75需联网 Apache-2.0
8#[derive(Debug, Clone)]
9pub struct RetryPolicy {
10 pub max_attempts: u64,
11 pub base_delay: Duration,
12 pub retry_on: RetryOn,
13}
14
15#[derive(Debug, Clone)]
16pub struct RetryOn {
17 pub retry_429: bool,
18 pub retry_5xx: bool,
19 pub retry_transport: bool,
20}
21
22impl RetryOn {
23 pub fn should_retry(&self, err: &TransportError, attempt: u64, max_attempts: u64) -> bool {
24 if attempt >= max_attempts {
25 return false;
26 }
27 match err {
28 TransportError::Http { status, .. } => {
29 (self.retry_429 && status.as_u16() == 429)
30 || (self.retry_5xx && status.is_server_error())
31 }
32 TransportError::Timeout
33 | TransportError::Connection(_)
34 | TransportError::Network(_) => self.retry_transport,
35 _ => false,
36 }
37 }
38}
39
40pub fn backoff(base: Duration, attempt: u64) -> Duration {
41 if attempt == 0 {
42 return base;
43 }
44 let exp = 2u64.saturating_pow(attempt as u32 - 1);
45 let millis = base.as_millis() as u64;
46 let raw = millis.saturating_mul(exp);
47 let jitter: f64 = rand::rng().random_range(0.9..1.1);
48 Duration::from_millis((raw as f64 * jitter) as u64)
49}
50
51pub async fn run_with_retry<T, F, Fut>(
52 policy: RetryPolicy,
53 mut make_req: impl FnMut() -> Request,
54 op: F,
55) -> Result<T, TransportError>
56where
57 F: Fn(Request, u64) -> Fut,
58 Fut: Future<Output = Result<T, TransportError>>,
59{
60 for attempt in 0..=policy.max_attempts {
61 let req = make_req();
62 match op(req, attempt).await {
63 Ok(resp) => return Ok(resp),
64 Err(err)
65 if policy
66 .retry_on
67 .should_retry(&err, attempt, policy.max_attempts) =>
68 {
69 sleep(backoff(policy.base_delay, attempt + 1)).await;
70 }
71 Err(err) => return Err(err),
72 }
73 }
74 Err(TransportError::RetryLimit)
75}
👆 带下划线的行可以点:点哪行,讲哪行。
Codex 传输层的重试间隔(codex-client/src/retry.rs:40-49;base 取 200 ms,lib.rs:266)
👆 公式里带下划线的每一项都可以点——点哪项,讲哪项。
这些间隔是给「偶发断线、服务端 5xx」准备的,量级是几百毫秒到一两秒。429 限流的重置时间常以秒甚至分钟计(项目测试桩给的 Retry-After 是 7 s),在这一层重试只会撞上同一个限流窗口——这就是 Codex 关掉 retry_429 的一个合理动机;Codex 没有在源码里写明原因。
⚠
常见误解:「大厂的代码处处是优雅的设计。」

Codex 需要知道「限流后多久能再试」时,并没有一个结构化字段可读:它用一条正则,从错误消息的文字里找「try again in 1.5s」这样的片段(下面两段)。能工作,但服务端改一下措辞就失效。好的工程常常是这样:在拿不到结构化信号的地方,老实地写一个启发式,并把它限定在一个小函数里。

Codex:Retry-After 从错误文字里刮 codex@8cabf5a:codex-rs/codex-api/src/sse/responses.rs:627-651需联网 Apache-2.0
627fn try_parse_retry_after(err: &Error) -> Option<Duration> {
628 if err.code.as_deref() != Some("rate_limit_exceeded") {
629 return None;
630 }
631
632 let re = rate_limit_regex();
633 if let Some(message) = &err.message
634 && let Some(captures) = re.captures(message)
635 {
636 let seconds = captures.get(1);
637 let unit = captures.get(2);
638
639 if let (Some(value), Some(unit)) = (seconds, unit) {
640 let value = value.as_str().parse::<f64>().ok()?;
641 let unit = unit.as_str().to_ascii_lowercase();
642
643 if unit == "s" || unit.starts_with("second") {
644 return Some(Duration::from_secs_f64(value));
645 } else if unit == "ms" {
646 return Some(Duration::from_millis(value as u64));
647 }
648 }
649 }
650 None
651}
👆 带下划线的行可以点:点哪行,讲哪行。
那条正则 codex@8cabf5a:codex-rs/codex-api/src/sse/responses.rs:684-690需联网 Apache-2.0
684fn rate_limit_regex() -> &'static regex_lite::Regex {
685 static RE: std::sync::OnceLock<regex_lite::Regex> = std::sync::OnceLock::new();
686 #[expect(clippy::unwrap_used)]
687 RE.get_or_init(|| {
688 regex_lite::Regex::new(r"(?i)try again in\s*(\d+(?:\.\d+)?)\s*(s|ms|seconds?)").unwrap()
689 })
690}
👆 带下划线的行可以点:点哪行,讲哪行。
CoreCoder v0.7.0:传输层把 429 也一起重试 CoreCoder@v0.7.0:corecoder/llm.py:262-278需联网 MIT
262 def _call_with_retry(self, params: dict, max_retries: int = 3):
263 """Retry on transient errors with exponential backoff."""
264 for attempt in range(max_retries):
265 try:
266 return self.client.chat.completions.create(**params)
267 except (RateLimitError, APITimeoutError, APIConnectionError):
268 if attempt == max_retries - 1:
269 raise
270 wait = 2 ** attempt
271 time.sleep(wait)
272 except APIError as e:
273 # retry 5xx server errors but not 4xx; base APIError has no status_code so read it defensively
274 status_code = getattr(e, "status_code", None)
275 if status_code and status_code >= 500 and attempt < max_retries - 1:
276 time.sleep(2 ** attempt)
277 else:
278 raise
👆 带下划线的行可以点:点哪行,讲哪行。

证据三问

问本课的回答
① 它解决什么问题?慢了知道慢在哪一步;错了知道该由哪一层、隔多久重试
② 为什么可能有效?不同失败的恢复时间差几个数量级:断线是毫秒级,限流按服务端给的窗口;放在同一层、同一个退避里重试,必然有一类被处理错
③ 实验上呢?T5 真实 trace:模型调用 6,617 ms、工具 34 ms;两次首帧 1 秒左右的轮次都对应被丢弃的调用

演进地图:你在这里

这一课属于演进图里「流式」那一步:流让你看得见每一帧,也就看得见时间花在哪。

模型接入层的演进:每一步都被上一步的真数字逼出来
  1. CoreCoder(OpenAI 兼容 + litellm 兜底)、本课项目 miniagent(标准库 urllib)
    解决了最短的路:CoreCoder 的 llm.py 净 259 行,换 provider 只改 base URL 与 key。
    卡在同一段带工具调用的对话,换成 Anthropic Messages,9 个语义位置里 9 个换了路径或类型(参数从字符串变成对象,5 条消息变 3 条);Codex 干脆不认 Chat:配置 wire_api = "chat" 直接报错。
  2. Pi 自建 pi-ai · OpenCode 用 Vercel AI SDK · Codex 只留 Responses
    解决了Pi 把 10 种协议与 40 个 provider 做成正交的两维;OpenCode 挂了 19 个 @ai-sdk/* 依赖;Codex 的 WireApi 只剩 1 个变体。
    卡在抽象层得替每一家补差异:OpenCode 逐 provider 改写消息的 transform.ts 有 1,909 行,还给 8 个 @ai-sdk 包打了补丁;而且抽象挡不住流式与缓存的细节。
  3. Anthropic 流式文档 · OpenAI function-calling 流式指南 · Codex 只收 output_item.done
    解决了开思考的一轮,第一帧 125.9 ms 就到;整包等回复的话,正文要等到 6,631 ms、工具调用 6,798 ms。
    卡在流式只让回复来得早,不让它便宜:T5 的 9 轮每轮整份重发,输入累计 15,921 token,是最后一轮 2,389 的 6.7 倍。
  4. Anthropic / OpenAI prompt caching · Codex prompt_cache_key · OpenCode applyCaching
    解决了按 usage 四段计价:T5 在 Claude Sonnet 5 价目下 $0.0140;CoreCoder 按全价估 $0.0371,是它的 2.64 倍。
    卡在缓存只省输入:开思考的 pd-v2-unlimited-s3 账单里输出占 94%;前缀里一处变、后面全失效——T10 刷新组第 7 轮 system 重建,命中从 2,072 掉到 294。(→ A9:能跑、能算账了,怎么知道它好不好?)

数字口径:pin 源码计数(Codex 8cabf5a、OpenCode v1.18.32、Pi 31b513e3、CoreCoder v0.7.0)、项目录制 T5 / T10 与逐轮 usage(本机 Qwen3-8B,token 按它的 tokenizer 数)、官方价格页快照 2026-09-26;由 a08-model-access.json 构建时现算。

一句话

📌
先按 span 拆开时间再找慢在哪;重试要分层,限流不在传输层硬重试。
🚦

先停在这里:回主线第 3 课,看每一帧 usage 怎么变成钱。
再深一层:给 miniagent 的 trace 加两个字段:每轮首帧时间、帧间最长静默;再写一条规则——首帧超过前几轮中位数两倍、又没有调用到达时,在 trace 里打「疑似被丢弃的调用」标签(A9 的失败归因会用到)。

下一个缺口

回主线:每轮都重发全部历史,钱花在哪?缓存能省多少? → 第 3 课

你可能会问