Chapter 4: 与 LLM 对话:API 调用、流式响应与错误恢复
调一次 API 看似只需三行代码。但在生产环境下,这三行代码后面藏着一百种失败方式。
┌─────────────── Harness ───────────────┐
│ │
│ Agent Loop │
│ │ │
│ ▼ │
│ ★ API 通信层 ★ ─────▶ LLM 服务 │
│ ┌───────────────────────┐ │
│ │ 流式传输 · 重试策略 │ │
│ │ 错误恢复 · 模型降级 │ │
│ │ Token 预算 · Prompt 缓存│ │
│ └───────────────────────┘ │
│ │
└───────────────────────────────────────┘
本章聚焦:Agent 与 LLM 之间的可靠通信
4.1 流式传输:为什么不等结果出来再显示
问题
LLM 生成一段回复可能需要 5-30 秒。如果等它完全生成好再一次性返回,用户会盯着一个空白屏幕发呆。有没有更好的方式?
思路
答案是流式传输(streaming)。LLM 边生成边发送,用户能看到文字逐字出现。这不只是体验问题——在 Agent 场景下,流式传输还有一个关键用途:让工具执行和 LLM 输出并行。
LLM 的 API 返回一系列事件:message_start -> content_block_start -> content_block_delta(多次) -> content_block_stop -> message_delta -> message_stop。每个 content_block_delta 携带一小段增量内容(一个文本片段、一段 JSON 碎片、一段思考过程)。
该系统在 API 服务模块中提供了两个入口函数:一个流式版本(AsyncGenerator,逐步产出事件),一个非流式版本(返回 Promise,用于不需要实时反馈的场景如压缩对话)。两者共享同一个底层实现,区别只是消费方式不同。
实现
在查询引擎的消息分发器中,流式事件被逐一处理。最重要的是 Token 用量追踪:
// 流式事件中的 Token 用量追踪(概念示意)
case 'stream_event':
if event.type == 'message_start':
currentMessageUsage = EMPTY_USAGE
currentMessageUsage = updateUsage(currentMessageUsage, event.message.usage)
if event.type == 'message_delta':
currentMessageUsage = updateUsage(currentMessageUsage, event.usage)
if event.type == 'message_stop':
totalUsage = accumulateUsage(totalUsage, currentMessageUsage)
message_start 重置计数器,message_delta 累加增量,message_stop 写入总账。这种三阶段追踪确保了即使流式传输中途出错,已经消耗的 Token 也不会被遗漏。
流式传输还带来了一个巧妙的优化机会:前面第 3 章提到的 StreamingToolExecutor。当 LLM 流式输出中出现一个完整的 tool_use block,不必等整个响应结束就可以开始执行工具。在流式循环中间就取回已完成的工具结果:
// 流式执行中取回已完成结果(概念示意)
if streamingToolExecutor and not aborted:
for result in streamingToolExecutor.getCompletedResults():
if result.message:
yield result.message
toolResults.append(...)
LLM 还在输出第三个工具调用,第一个工具的结果已经拿到了。这种重叠执行在"同时读三个文件"的场景下效果显著。
4.2 重试:从简单退避到分级策略
问题
网络抖动、API 限速、服务端过载——这些是调 API 的家常便饭。一个简单的"失败就重试"够用吗?
思路
不够。盲目重试有两个致命问题。第一,如果所有客户端在同一时刻重试,会制造"惊群效应"(thundering herd),把本来快恢复的服务再次压垮。第二,不是所有错误都值得重试——401 认证失败重试一百次也没用,而 529 过载可能等 30 秒就好。
该系统的重试引擎采用了经典的指数退避 + 随机抖动,但在此基础上叠加了三层差异化策略:按错误类型、按请求来源、按运行模式。
最有趣的设计是:重试引擎本身也是一个 AsyncGenerator。重试等待期间,它不是沉默地 sleep,而是 yield 出系统消息,让用户看到"正在重试..."的提示。这解决了一个 UX 问题:用户不知道程序是挂了还是在等。
实现
指数退避的实现:
// 指数退避 + 随机抖动(概念示意)
function getRetryDelay(attempt, retryAfterHeader?, maxDelayMs = 32000):
// 优先使用服务端指定的 retry-after 值
if retryAfterHeader:
seconds = parseInt(retryAfterHeader)
if isValidNumber(seconds): return seconds * 1000
// 指数退避:500ms 起步,每次翻倍,上限 32 秒
baseDelay = min(BASE_DELAY_MS * pow(2, attempt - 1), maxDelayMs)
// 25% 随机抖动,避免客户端同步重试
jitter = random() * 0.25 * baseDelay
return baseDelay + jitter
三个层次:优先服务端 retry-after 头(它知道什么时候能恢复),否则从 500ms 起步、每次翻倍、上限 32 秒,最后加 25% 随机抖动避免客户端同步。
4.3 529 过载:不是所有请求都值得重试
问题
当 LLM API 返回 529(服务端过载),所有请求都应该重试吗?
思路
不应该。这是一个违反直觉但至关重要的设计决策:在服务端过载时,减少请求量比保证每个请求成功更重要。
系统设计者把请求分成两类:前台请求(用户正在等结果的)和后台请求(摘要生成、标题生成、建议等)。后台请求在遇到 529 时直接放弃,因为用户感知不到它们失败,而重试只会加剧过载。
实现
前台请求的来源被明确枚举:
// 允许重试 529 的前台请求来源(概念示意)
FOREGROUND_529_RETRY_SOURCES = Set([
'repl_main_thread', 'sdk', 'agent:custom',
'compact', 'hook_agent', 'auto_mode',
// ...
])
没在这个集合里的来源——提示建议、标题生成、会话记忆等——直接失败。注释里写得很清楚:"每次重试是 3-10 倍的网关放大,用户根本看不到这些失败"。
对于前台请求,连续 3 次 529 后会触发模型降级:
// 模型降级机制(概念示意)
if is529Error(error):
consecutive529Errors++
if consecutive529Errors >= MAX_529_RETRIES:
if options.fallbackModel:
throw FallbackTriggeredError(options.model, options.fallbackModel)
降级错误被外层 try/catch 捕获,切换到备用模型重试。比如 Opus 过载了,降级到 Sonnet——不如原来聪明,但至少能用。
4.4 持久重试:无人值守的韧性
问题
在 CI/CD 或自动化场景下,该 Agent 系统可能无人值守运行几小时。遇到 API 限速怎么办?等多久?
思路
普通场景下,重试 10 次失败就放弃了。但对于无人值守场景(通过环境变量开启),该系统提供了一种"等到天荒地老"的模式。
这里有一个工程细节值得注意:长时间等待期间,宿主环境(比如容器编排系统)可能因为空闲而杀死进程。解决方案是每 30 秒发一次"心跳"。
实现
持久重试的参数设定:
// 持久重试参数(概念示意)
PERSISTENT_MAX_BACKOFF = 5 minutes // 退避上限 5 分钟
PERSISTENT_RESET_CAP = 6 hours // 最长等 6 小时
HEARTBEAT_INTERVAL = 30 seconds // 心跳 30 秒
长时间 sleep 被切成 30 秒的块。每个块结束时 yield 一条系统消息,宿主看到标准输出有活动,就不会判定进程"僵死"。
// 心跳式等待(概念示意)
remaining = delayMs
while remaining > 0:
if signal.aborted: throw UserAbortError()
yield createSystemErrorMessage(error, remaining, attempt, maxRetries)
chunk = min(remaining, HEARTBEAT_INTERVAL)
await sleep(chunk, signal)
remaining -= chunk
429 限速还有一个特殊处理:如果服务端返回了限速重置头(告诉你什么时候限速结束),直接等到那个时间点,而不是傻乎乎地指数退避。窗口式限速(比如"5 小时限额")的重置时间通常是精确的。
4.5 输出被截断:分级恢复
问题
LLM 的输出有长度限制(max_output_tokens)。当输出被截断时(stop_reason === 'max_output_tokens'),Agent 正在写的代码可能写到一半。怎么办?
思路
系统设计者实现了一套三级恢复机制,核心思想是先试最便宜的方案。
第一级:也许根本不需要那么多输出空间。系统默认把输出限额压到 8K,因为数据分析显示 p99 的输出只有约 5000 token。如果触碰了这个低限额,先升级到 64K 重试——一次干净的重试换取 8 倍的容量节约。
第二级:如果 64K 还不够,注入一条特殊消息让 LLM 从断点继续写。最多重试 3 次。
第三级:3 次都失败了,把错误暴露给用户。
实现
第一级升级:
// 输出限额升级(概念示意)
if capEnabled and noOverrideSet:
nextState = {
...state,
maxOutputOverride: ESCALATED_MAX_TOKENS, // 64,000
transition: { reason: 'max_output_escalate' },
}
state = nextState
continue // 用更高限额重试同一个请求
第二级恢复消息的措辞值得细看:
// 截断恢复消息(概念示意)
recoveryMessage = createUserMessage({
content:
"Output token limit hit. Resume directly -- no apology, no recap " +
"of what you were doing. Pick up mid-thought if that is where the " +
"cut happened. Break remaining work into smaller pieces.",
isMeta: true,
})
"No apology, no recap"——这不是礼貌问题,是 Token 预算问题。LLM 有一个坏习惯:被打断后喜欢道歉、总结之前做了什么。这些"客气话"会占用宝贵的输出空间,可能导致再次被截断,形成死循环。
还有一个设计细节:截断错误在流式循环中被"扣留"(withheld),不立即 yield 给外部。如果过早暴露错误,SDK 调用者可能提前终止会话,让恢复机制没机会运行。只有当三次恢复都失败后,错误才被释放。
4.6 Token 预算:三道保险
问题
Token 是 LLM 世界的货币。怎么防止失控的消耗?
思路
该系统在三个维度管控 Token 预算,每个维度解决不同的问题:
- 输出限额(per-request):防止单次回复过长。默认 8K,升级到 64K,上限因模型而异。
- 上下文窗口(per-conversation):防止对话历史撑爆窗口。200K 或 1M,通过压缩机制管理(下一章详述)。
- USD 预算(per-session):防止账单失控。SDK 调用者可以设硬性上限。
实现
输出限额的容量保留优化体现了数据驱动的思维:
// 基于数据分析的输出限额(概念示意)
// 数据分析显示 p99 输出约 4,911 token,32k/64k 默认值会过度预留 8-16 倍
CAPPED_DEFAULT_MAX_TOKENS = 8_000
ESCALATED_MAX_TOKENS = 64_000
不到 1% 的请求会触碰 8K 限额,它们被升级到 64K——代价是一次额外的 API 调用,收益是 99% 的请求省了 8-16 倍的容量预留。
USD 预算控制在查询引擎层面,每处理完一条消息就检查累计花费:
// USD 预算断路器(概念示意)
if maxBudgetUsd != undefined and getTotalCost() >= maxBudgetUsd:
yield { type: 'result', subtype: 'error_max_budget_usd', ... }
return
这是一个硬性断路器。不管 Agent 正在做什么,预算到了立刻停。
4.7 模型选择:运行时的动态决策
问题
不同用户、不同场景应该用什么模型?谁来决定?
思路
模型选择不是启动时一锤子买卖。它有一条优先级链(用户显式指定 > 环境变量 > 订阅级别默认值),并且在运行时可以动态调整。
最有趣的是 opusplan 模式:规划阶段用 Opus(最强大脑),执行阶段用 Sonnet(高效助手)。这是一个成本优化——大部分 Token 消耗在执行阶段(读文件、写代码),用较便宜的模型就够了,把昂贵的模型留给需要深度思考的规划环节。
实现
运行时模型切换的逻辑:
// 运行时模型选择(概念示意)
function getRuntimeMainLoopModel(params):
// opusplan 模式:规划阶段用 Opus,但超长上下文除外
if userSetting == 'opusplan'
and permissionMode == 'plan' and not exceeds200kTokens:
return getDefaultOpusModel()
// haiku 在规划阶段升级到 sonnet(规划能力不足)
if userSetting == 'haiku' and permissionMode == 'plan':
return getDefaultSonnetModel()
return mainLoopModel
注意 exceeds200kTokens 这个条件:当上下文超过 200K token 时,即使在规划阶段也不用 Opus。这是因为 Opus 在超长上下文下的性价比不如 Sonnet,花两倍的钱但不一定得到更好的规划。
另外,Haiku 在规划阶段也会被升级到 Sonnet。逻辑很清楚:Haiku 的规划能力不足以驾驭复杂任务的分解和编排。
4.8 错误分类:每种故障都有对应的出路
问题
API 调用可能遇到几十种不同的错误。怎么给用户有用的提示,而不是千篇一律的"出错了"?
思路
错误处理模块中有一个庞大的错误分类器。它的设计原则是:每种分类对应一种可操作的指引。不是告诉用户"429 错误",而是告诉他"限速了,去 用量设置页面 开启额外用量"。
实现
分类树的主干结构:
超时 --> "Request timed out"(自动重试)
图片过大 --> "Image was too large"(提示缩小)
429 限速 --> 细分:
有 quota headers --> 解析剩余额度,显示重置时间
需要 Extra Usage --> "run /extra-usage to enable"
其他 --> 显示服务端原始消息
Prompt Too Long --> 触发 reactive compact
PDF 错误 --> 细分页数/密码/格式
401/403 认证 --> 细分:
OAuth 撤销 --> "Please run /login"
组织被禁用 --> 区分环境变量 vs OAuth 路径
余额不足 --> "Credit balance is too low"
每个分支产出一个助手消息,带有错误类型标识字段。这个字段被上层的恢复机制消费——比如 prompt_too_long 类型会触发 reactive compact,max_output_tokens 会触发截断恢复。错误分类不只是给人看的提示,更是给机器看的恢复信号。
4.9 Prompt 缓存:省钱的隐形机制
问题
Agent 每轮循环都要把完整的消息历史发给 API。同样的系统提示词发了 50 遍,Token 算了 50 次钱。有没有办法只付一次?
思路
API 提供商的 Prompt Caching 机制允许标记消息中的"缓存断点"。被标记内容的 Token 在第一次请求时正常计费,后续请求如果前缀匹配,只收 1/10 的价格。
该系统在两个地方设置缓存断点:系统提示词和最近几轮对话的消息末尾。这样,只要系统提示词和对话前缀不变,每轮循环只为新增的内容付全价。
缓存的 TTL 默认 5 分钟(ephemeral),但对符合条件的用户可以扩展到 1 小时。TTL 的选择被"锁存"在会话启动时——防止远程配置在请求中途更新,导致同一会话内混用不同 TTL,反而破坏缓存。
实现
缓存控制的生成逻辑:
// 缓存控制生成(概念示意)
function getCacheControl({ scope, querySource }):
return {
type: 'ephemeral',
ttl: should1hCacheTTL(querySource) ? '1h' : undefined,
scope: scope == 'global' ? 'global' : undefined,
}
1 小时 TTL 的判断内部做了两层判断:用户是否有资格(内部用户或订阅且未超量),查询来源是否匹配白名单模式。两者都满足才开启 1h TTL。
这里有一个微妙的稳定性考量:资格和白名单在首次查询时被锁存到启动状态,整个会话不再变化。原因是:"防止在远程配置磁盘缓存在请求中途更新时出现混合 TTL"——如果 TTL 在会话中途从 5min 变成 1h,新请求的 cache_control 和旧请求不同,服务端会认为是新的前缀,之前缓存的内容全部失效。
4.10 小结
与 LLM 对话的可靠性是一门工程纪律,需要在多个维度同时防御:
- 流式传输降低感知延迟,同时支持工具并行执行
- 指数退避 + 分级策略确保重试不会加剧过载
- 输出截断三级恢复把大部分截断错误消化在内部
- 模型降级在高负载时保持服务可用
- 错误分类让每种故障都有可操作的恢复路径
- Prompt 缓存在不改变行为的情况下大幅降低成本
这些机制的共同目标是一句话:宁可慢一点,也不能挂。下一章,我们将面对另一个硬约束:对话越来越长,上下文窗口装不下时,系统如何优雅地"遗忘"。
思考题
-
为什么后台请求在 529 时直接放弃而不是用更温和的方式(比如延迟重试)?提示:思考 N 个客户端同时重试时的总请求量。
-
输出截断恢复的第二级向 LLM 注入"no apology, no recap"消息。如果 LLM 不听这个指令怎么办?系统有没有备用方案?
-
Prompt 缓存的 TTL 为什么要在会话开始时"锁存"?如果允许动态变化,最坏情况下会发生什么?