加载中...
  • 流式响应、Reasoning 与重试:不要把模型输出当一段字符串 loading

    吃透 AI Agent 开发 · 第 08 篇 · 第二章 · Agent Loop

    模型返回的可能是文字、思考、工具调用、用量和警告。正确拆分这些 part,才能同时做好 UI、审计和下一轮请求。

    模型返回的可能是文字、思考、工具调用、用量和警告。正确拆分这些 part,才能同时做好 UI、审计和下一轮请求。

    流式不是“把字符串一点点打印”

    一个响应可能先吐出几段文本,接着给出 reasoning,再发出工具调用,最后才有 usage 和完成原因。终端只想显示适合用户阅读的部分,审计需要保存调用边界,下一轮模型又必须拿到协议要求的 assistant parts。三者看到的是同一条流,不应该拥有同一份展示逻辑。

    可以把流看成一卷胶片:画面、导演批注、场记和剪辑标记同时经过。观众只看成片,剪辑师却要保留原始胶片。Agent 的 onText 不能承担全部职责。

    先把事件分成五类

    sequenceDiagram
      participant M as Model
      participant N as Normalizer
      participant U as UI
      participant A as Audit
      participant L as Loop
      M->>N: text/reasoning/tool/usage/finish
      N->>U: user-visible text + progress
      N->>A: redacted event record
      N->>L: protocol-preserving assistant parts
      L->>M: next request with required history
    

    规范化层应该尽早把供应商差异翻成内部事件:text.deltareasoning.deltatool.callusagefinish。UI 不应该解析 provider 私有字段,审计也不应该依赖终端已经显示过什么。

    重试前先问“哪一层出了问题”

    失败对象 能否重试 重试时必须保留
    首 token 前的网络失败 通常可以 原始消息、幂等请求标识
    reasoning 传输中断 视 provider 而定 已接收 part 和完成原因
    工具参数校验失败 不应盲重试 tool call、校验错误、修正提示
    外部写操作超时 先查状态 目标、请求 id、已知副作用
    type StreamPart =
      | { kind: 'text'; delta: string }
      | { kind: 'reasoning'; delta: string }
      | { kind: 'tool-call'; id: string; name: string; args: unknown }
      | { kind: 'usage'; input: number; output: number }
      | { kind: 'finish'; reason: 'stop' | 'tool-calls' | 'error' }
    
    function visibleText(part: StreamPart): string {
      return part.kind === 'text' ? part.delta : ''
    }
    

    这段代码故意没有把 reasoning 丢掉,它只是不会进入用户可见文本。保存和展示是两件事,混在一起就会在下一轮请求或审计时丢证据。

    q-code 中要看的四个交接点

    顺序 路径 追踪问题
    1. 请求循环 src/agent/loop.ts SDK 的 reasoning part 怎样回到消息历史
    2. 重试策略 src/agent/retry.ts 哪些错误被判定为可重试
    3. provider 配置 src/runtime/reasoning-config.ts reasoning effort 如何统一表达
    4. DeepSeek 兼容层 src/runtime/deepseek-compat.ts 私有字段如何避免污染公共协议

    失败反例:重试只发送最后一段文本

    重试时只发送最后一段文本,导致模型丢失工具调用上下文。第一次响应里明明有 assistant 的 tool call,应用却只把“我准备读取文件”这句文字放回请求,第二次模型会以为工具从未调用,可能重复发起动作,或者回答与真实状态不符。

    修复方式是保存协议级 transcript,而不是保存 UI 字符串。每次重试先记录上次请求的完成原因,再判断是重新请求模型、重新执行工具,还是查询外部状态。用户看到的“正在重试”应该对应一个明确的分支,而不是掩盖了状态未知。

    做一次流式故障演练

    练习:用假 provider 依次发出文本、reasoning、tool call、usage、finish 五类事件,然后在三处截断:首 token 前、工具调用后、usage 到达前。你需要产出一条 UI 文本、一份脱敏审计记录和一份可以继续下一轮的消息历史,三者都要说明缺了什么。

    再加入一次 provider 切换,确认 reasoning 不会被错误地当作普通文本,也不会因为换模型而丢失工具调用的 assistant part。能解释每个事件去了哪里,才算真正理解流式 Loop。

    Chunk 边界是网络决定的,不是语义决定的

    工具参数 {"path":"package.json"} 可能被拆成十几个 delta,UTF-8 字符也可能跨网络包。应用不能把每个 chunk 当作一个完整 JSON,更不能在参数尚未闭合时执行工具。

    一个 assembler 通常按 responseId、partIndex 和 toolCallId 聚合:文本 delta 追加到文本 part,reasoning 进入不透明 part,工具参数先累积原始片段,收到完成事件后再 parse 和 schema 校验。若流提前结束,半个工具调用必须被标成 incomplete,不能猜右括号。

    interface ToolCallAssembly {
      callId: string
      name?: string
      jsonFragments: string[]
      completed: boolean
    }
    
    function finalizeToolCall(value: ToolCallAssembly): unknown {
      if (!value.completed || !value.name) {
        throw new Error(`incomplete tool call: ${value.callId}`)
      }
      return JSON.parse(value.jsonFragments.join(''))
    }
    

    测试时把同一响应随机切成 1 到 50 字节的 chunk,最终 assistant message 应完全相同。这类随机切片比手写两个固定 delta 更容易发现解析器对边界的错误假设。

    “已经显示的文字”不等于“已经提交的消息”

    模型流到一半网络中断,用户可能已经看到两段文字。若应用把这两段直接写进正式 transcript,再重试生成一份完整回答,会在历史里留下两个 assistant 消息;若完全不保存,界面刷新后又会凭空消失。

    可以区分 pending view 与 committed message。流式 delta 先进入当前视图缓冲,只有收到合法 finish 或明确的中断收尾后,才形成一条带状态的消息。中断消息可以保存可见前缀和 incomplete: true,下一轮默认不把它当成完整事实;重试成功后,界面把旧前缀归档为“上次未完成”。

    稳定 Markdown 前缀也建立在这条边界上:已闭合段落可以缓存渲染,未闭合代码围栏留在 pending 尾部。无论 UI 怎样优化,协议 assembler 都保留完整 part,不能拿渲染缓存替代消息历史。

    重试时间线要知道自己重试了哪一步

    一次请求可能经历:建立连接失败、模型流中断、工具参数不合法、工具执行超时、下一轮模型 429。五个失败点都显示“正在重试”,用户无法判断是否可能产生副作用。

    09:12:03 step=2 model request started attempt=1
    09:12:04 provider=429 retry-after=2s safe=true
    09:12:06 step=2 model request started attempt=2
    09:12:08 model finish=tool-calls call=c18
    09:12:08 tool=c18 policy=allow
    09:12:18 tool=c18 timeout delivery=unknown safe-retry=false
    

    时间线清楚地说明:模型请求重试安全,工具调用不安全。此时 Loop 应停止自动重试,进入状态检查;若界面只保留最后一行“超时”,重要上下文就丢了。

    退避还要服从总预算。一次请求最多 60 秒,前两次已经花 45 秒,就不能再按固定策略睡 30 秒。计算下一次 delay 时同时看 provider 建议、指数退避、剩余 deadline 和用户取消信号。

    心跳用于解释等待,不用于伪造进度

    reasoning 模型可能很久没有用户可见文本。界面可以每隔一段时间显示“仍在等待模型,已用 30 秒”,但心跳不是模型事件,也不应写进 assistant transcript。它属于本地诊断状态。

    首 token 慢、流中途停滞和总请求超时是三个阈值。10 秒没有首 token可以提示等待,30 秒可以记录 slow warning,60 秒没有任何新 part 可以标记 stalled;真正终止仍由独立 request timeout 决定。否则“慢”与“失败”会被同一个阈值混淆。

    日志只记录脱敏 endpoint、模型名和耗时,不能为了排障打印 API Key 或完整请求体。错误对象常含原始 headers,进入审计前必须清理。

    Backpressure 发生在展示层,也会拖累协议层

    如果每个 token 都同步做 Markdown 全量解析和磁盘写入,消费速度可能跟不上网络流。缓冲区不断增长,内存上升,甚至导致 provider 连接断开。常用做法是文本 delta 按 16 到 50 毫秒批量刷新 UI,关键边界如 tool call、finish 和 error 立即处理。

    审计写入可以放入有界队列。队列满时,不能阻塞模型无限等待,也不能静默丢关键事件;可以丢弃高频进度采样并增加一条 droppedCount,工具开始/结果和结束原因则必须保留。进程退出前执行有时限的 flush。

    这套策略要用慢消费者测试:让 UI listener 每次阻塞 100 毫秒,模型在一秒内发 500 个 delta,确认 assembler 完整、UI 批量更新、关键审计事件不丢。性能优化才不会以协议正确性为代价。

    Provider 切换需要能力协商

    统一配置里写 reasoningEffort: high,并不代表每个模型接受相同字段。有的 provider 使用枚举,有的用 thinking 对象,有的模型不支持。适配层应根据能力映射或明确拒绝,不应把未知字段原样透传。

    tool choice 也类似。autorequired 和指定函数的支持程度不同;某些 reasoning 模型与强制工具选择组合有限制。若用户显式要求 required,适配层不能为了让请求成功而悄悄改成 auto,那会吞掉调用意图。自动默认值可以按兼容性降级,显式约束则应给清晰错误。

    能力协商的结果可以进入运行摘要:当前 provider 是否支持 reasoning、工具并行、结构化输出和 usage 流。Loop 依据能力选择合法路径,业务代码不需要到处判断供应商名称。

    缓存重试结果时小心动态前缀

    模型请求重试若加入当前时间、随机 ID 或完整错误栈,会改变 Prompt 前缀,使 provider cache 无法复用。稳定 system 和工具 schema 应保持在前,重试说明作为本轮尾部的短动态上下文。这样既保留错误反馈,也不会为了重试破坏整个稳定前缀。

    但缓存命中不能改变语义。已经发生的 tool result 必须进入消息账本,即使这让前缀变化;为了 cache 省 token 而省略副作用证据,会让模型重复动作。成本优化永远排在协议完整之后。

    本文目录
    本文目录