Appearance
第 4 章 让最小循环应对真实任务
本章要解决的问题
第 3 章的 while 循环已经能运行,但它默认模型参数正确、工具很快结束、任务不会中断。这些假设在真实任务里都会失效。本章从具体故障出发,沿“工具说明 → 调用校验 → 调度执行 → 结果记录”补齐循环边界。
4.1 最小循环会怎样坏掉
第 3 章的最小版本大致是:调用模型,执行它提出的工具,把结果放回消息,然后继续。拿它跑几次真实任务,很快会遇到下面这些情况:
| 现场 | 如果不处理 | 本章怎样补 |
|---|---|---|
| 模型输出了半截参数 | 解析报错,严重时整个进程退出 | 4.4 完整接收并逐层校验 |
模型把 read_file 写成 readfile | 找不到函数,循环无法继续 | 4.2 定义可检查契约,4.4 返回可修错误 |
| 同一批里既读文件又跑测试 | 全部并行可能互相影响,全部串行又浪费时间 | 4.5 按依赖和副作用调度 |
| 用户按下取消时命令仍在运行 | UI 显示停止,后台仍继续改文件或占端口 | 4.5 传播取消并等待安全点 |
| 工具成功后、结果写入前进程崩溃 | 恢复时再次执行,副作用发生两次 | 4.5 记录 attempt 与副作用状态 |
| 每次失败都原样重试 | 持续花费 token 和时间,却重复同一个错误 | 4.3 限制循环,4.4 给出修复信息 |
这些问题不能靠把 while 写得更长解决。它们发生在一条连续的执行链上:模型先根据工具说明做选择,运行时再接收并校验调用,随后调度执行,最后记录结果。任一交接点含糊,都会变成上表中的故障。
4.2 第一步:让工具成为可检查的接口
4.1 里的 readfile 为什么会让循环卡住?模型生成的名字与程序注册的 read_file 不一致。所以我们需要给工具的注册做一个规范,或者叫契约。它不能保证模型永不写错,但能让运行时立即判定“工具不存在”,并把可用名称和修正方式返回给模型,而不是崩溃或含糊重试。所以第一步不是增加更多工具,而是把每个工具定义成模型和程序都能检查的接口。
这里先不展开工具种类。常见能力包括读、写、编辑、grep、glob、Shell 和受限代码执行,但产品可以合并、拆分或替换;完整对照见附录 D,文件、Shell 和检索分别在第 7、8、11 章说明。本章只处理所有工具共用的接口规则。
一份工具契约分成两面。模型看到的一面帮助它选对和改对;运行时看到的一面帮助程序安全地执行、调度和恢复。两面描述的是同一个工具,不能各写一套互相矛盾的规则。
模型看到的契约:帮助它选对和改对
前端会认真设计按钮文案、禁用态和错误提示,因为用户通过界面理解系统能力。模型也一样:它看不到工具的实现代码,主要通过请求里的工具声明理解能力;系统提示和示例也会影响它怎样选择。
模型直接看到的接口包括工具名、说明、参数 schema、返回值和错误信息。这里的参数 schema 通常使用 JSON Schema,也就是用 JSON 描述允许哪些字段、字段类型和必填项。
同一个 read_file,如果描述含糊、出错只回一句 failed,模型就很难改正;如果说明清晰,错误也写明"错在哪、怎么改",模型更容易一次调用成功。下一节的 tools 字段就是这份接口定义所在的位置。
工具大小可以按一条简单规则判断:一次调用完成一个目的,使用一种输入输出格式,承担同一类风险。不要把读取、删除和发布塞进一个 workspace(action, ...) 万能工具;也不要把一个自然动作拆成十个只能按固定顺序调用的小工具。
描述里至少写清"何时用""何时不用"、路径相对谁、返回格式、输出是否可能截断以及如何继续。哪些能力适合专用工具,哪些适合用通用工具组合,第 10 章会继续讨论。
参数稍复杂时,再给一个正确示例和一个常见错误示例。例如时间字段不仅写“RFC 3339”,还要给出 2026-08-16T10:30:00+08:00;搜索工具则明确说明“glob 只匹配文件名,不能搜索文件内容”。
示例、描述和 schema 都是工具接口的一部分,修改后要像改 API 一样重新测试工具选择与参数准确率。Agent 经常选错工具时,先检查这些接口信息是否含糊,不要直接归因于模型不够强。
运行时看到的契约:帮助程序正确调度
工具名、参数 schema 只能解决 4.1 中的“选错、传错”。多个调用能否并行、取消是否真的停止、崩溃后能否重放,还需要生产注册表保存一组不一定展示给模型的运行信息。这些字段不进入上下文,但程序会据此决定是否并行、是否重试、怎样取消和限制输出:
| 运行字段 | 回答什么 | 程序据此做什么 |
|---|---|---|
readOnly | 是否不会改变外部状态 | 只读工具可更宽松地并行、重试 |
concurrencySafe | 能否和其他调用同时执行 | 安全调用进入有数量上限的并行队列,不安全调用单独执行 |
idempotent | 同一输入重放是否产生相同最终状态 | 只有幂等工具可自动重试(4.5.3) |
cancellable | 是否真正响应中止信号并清理子进程 | true 必须代表实现真的会停止,不能只在 UI 上显示按钮 |
risk(low/medium/high) | 副作用风险分级 | 选择沙箱强度与审批档位(第 9 章) |
outputBudget(上限 + 截断/写入文件) | 输出可能多大、超了怎么办 | 限制进入上下文的内容(第 8、13 章) |
未知值一律按保守侧处理——不并行、不自动重试、需要更严格的权限判断。
参数从模型到执行器要保持原样:先按 schema 校验,再把原值交给工具。不要静默改路径、补 Shell 参数或扩大搜索范围;确需规范化时,校验规范化后的值,并在结果里返回实际执行参数。
返回值也要有稳定格式,至少区分成功和失败:
- 成功时给数据,可附带
truncated(是否截断)与continuation(如何续读); - 失败时给机器可判断的
code(如INVALID_ARGS/UNKNOWN_TOOL)、给人与模型阅读的message,以及告诉程序能否重试的retryable。
message 要同时告诉模型哪里错、收到什么、怎样修;code 和 retryable 供程序决定重试或升级。第 11 章会展示检索工具如何显式返回截断与继续方式。
4.3 第二步:把工具契约接进循环
工具契约本身不会自动修复任何错误。第 3 章只画了“模型 → 工具 → 模型”;生产实现还要在每个交接点真正使用 4.2 的契约。以 read_file 为例,完整流程可以压缩成下面这张表:
| 阶段 | 传递的信息 | 谁负责 |
|---|---|---|
| 声明工具 | 名称、用途、参数约束 | Agent 提供给模型 |
| 提出调用 | 调用 id、工具名、参数 | 模型生成,但不执行 |
| 校验执行 | 查注册表、验参数、过权限、调用处理函数 | Agent 运行时 |
| 回填结果 | 调用 id、成功或错误、受限输出 | Agent 返回给模型 |
真正需要稳定传输的是调用和结果。模型的完整内部思考是否可见,不影响这条执行链。
把这条链路放进循环,直到模型不再调用工具,或达到轮数、时间、token、费用等预算上限:
text
模型决定下一步 → 运行时校验并执行 → 结果按调用 id 回填 → 模型继续决定在最小循环外,还要补下面五条规则:
要点一:把工具放进注册表,不要写成一长串代码分支。 每个工具以"名字 + 说明 + 参数 schema"注册到一张表,执行器再按名字找到对应函数。这样工具列表可以直接生成并放入上下文,也可以按会话配置增删(dsh 的 preset),或在运行时从 MCP server 接入新工具(第 17 章)。
如果把所有工具写成一长串 if/else,后续扩展和按会话配置都会很麻烦。
要点二:工具结果必须用调用 id 与调用配对。 本章的 OpenAI Chat Completions 示例用 role="tool" 和 tool_call_id;其他模型接口的字段不同,例如 Anthropic 用 tool_use_id 配对 tool_use/tool_result 内容块。共同规则是:每个调用都有稳定 id,结果必须回到对应 id。
进一步做法是把模型消息、工具调用和结果按顺序追加到持久日志,重启后再从日志恢复会话。这就是第 14 章要讲的会话记录。
要点三:必须有明确的停止条件和预算上限。 模型不再调工具时可以自然结束;达到轮数、token、时间或费用上限时必须停止。用户取消、审批拒绝、连接或协议错误也可能结束当前 turn。上限防止模型反复执行相同操作、一直无法完成;生产实现还要为每种停止原因写入可恢复的结束状态(第 14、19 章)。
要点四:模型的输出不可信。 执行流程中的“解析 arguments”,最简单的做法是一行 JSON.parse,但它假设模型永远输出合法 JSON。生产中会遇到截断、缺字段、多个对象拼在一起等情况,所以从 token 到执行之间必须逐步校验并处理错误,4.4 会详细说明。
要点五:执行时机与并发是设计点,不是默认值。 流式输出下,各家开始执行工具的时机不同:codex 在单个输出项完成后启动,dsh 和 pi 等整条消息收集完再执行。pi 会把增量解析出的参数用于 UI 预览,但只有完整工具调用才会进入校验和执行。第 19、20 章还会继续讨论这两点。
循环运行后发生了什么?
循环的主干没有变:模型决定调用工具 → 程序执行 → 把结果发回模型 → 模型再决定下一步。生产实现的复杂度主要来自如何限制循环、处理错误并保证安全。
pi 与 dsh 都有与这个循环结构相近的 TypeScript 实现。pi 更短,适合先看循环、校验和队列;dsh 更适合继续理解事件日志和插件化。源码入口统一放在附录 B。
两个要警惕的细节
① 轮数/超时上限不是可选配置,是安全机制——没有它,模型一直无法完成时就会无限循环并持续产生费用。
② "输出是合法 JSON"是最危险的隐含假设——一行 JSON.parse 就能让整个进程崩溃,而且崩溃的时机恰恰是模型犯错的时候,雪上加霜。怎么系统性地修,是下一节的主题。
4.4 第三步:拒绝不完整和不合法的调用
4.2 定义了合法调用应该长什么样,4.3 把检查位置接进循环。现在才能处理 4.1 的前两类现场:模型输出半截参数,或者写出不存在的工具名。模型是文本生成器,它生成的工具调用可能不完整或格式错误。这一节重点说明循环怎样识别并回传这些错误;第 5 章再把检查放进完整的工具执行过程。
4.4.1 模型输出可能怎样出错
text
① 语法残缺 JSON 少括号、引号未闭合,常见于截断或流式拼装错误
② 结构不符 JSON 合法,但缺 required 字段、类型不对(字符串传成了数字)
③ 幻觉工具名 调了 readfile(没有下划线)、甚至 Claude 风格的 "Read"
④ 拼贴输出 一次调用里粘了两个 JSON 对象:{"a":1}{"b":2}(模型把多次调用挤进了一格)只调用一次 JSON.parse 会让这四种错误直接变成异常,严重时导致进程退出。生产系统需要分别处理它们,下面看四个项目的做法。
4.4.2 输出被截断时不要执行
四个实现在这里的选择完全一致:判定为截断的响应,其中的工具调用一律不执行——不管增量 JSON 碰巧能不能解析、能不能通过 schema。差别只在边界细节:
- dsh 把所有 tool-call 块直接过滤出执行器,半截参数根本不进执行;turn 级标记让本步立即结束
- grok 把整个响应判为失败,一个调用都不产出,且刻意不重试——同样的上限重采样只会再截断一次,先修上限再谈重试
- pi 不执行消息中的任何工具调用,逐个返回“参数可能被截断,请重新发起完整调用”的错误
- codex 等 function-call 输出项完成后再解析;断流等传输错误终止当前 turn,完整收到但解析失败的 arguments 走工具错误回传路径
4.4.3 等参数收完整后再解析 JSON
流式输出下,arguments 会分成多个 chunk 到达。chunk 就是流中的一小段数据。一个看似更快的做法是边接收边执行,但四个项目都不会这样做。dsh 的 BlockAssembler 先拼接完整字符串;pi 可以把“尽力解析”的 partial arguments 发给 UI 做预览,但文档明确要求消费者把它当不完整数据,真正执行仍要等 toolcall_end 后再校验。
原因很直接:即使提前解析,也必须等参数完整后才能执行;同时,拼接器还要兼容不同模型接口的流式事件差异:
text
dsh BlockAssembler 处理三种异常情况:
· 已经收到完整的 block-end → 忽略迟到的 delta,避免错误适配器污染结果
· 适配器不发 block-start/end → 自动创建 partial,兼容只有 delta 的协议
· 缺 callId/name → 生成一个可识别的默认值,而不是直接崩溃4.4.4 解析失败时按顺序尝试修复
JSON 已经接收完整但仍不合法时,可以先尝试修复。grok 依次处理多种真实出现过的模型错误(xai-grok-shell 的 prepare_tool_call):
text
输入: 模型的 arguments 原始字符串
↓ ① 转换空参数 "" → "{}"(零参工具常收到空串)
↓ ② 整体解析 serde_json::from_str —— 成功则跳到 ④
↓ ③ 拼贴拆分 {"a":1}{"b":2} → 拆成多个对象,
│ 逐个尝试、取第一个能通过该工具 schema 的
│ (并记警告:模型本该发两次独立调用)
↓ ④ 保留原文 还是失败?包成 {"raw": 原文} 继续走,
│ 让后续校验报"缺字段"而不是"非 JSON"
↓ ⑤ 参数名映射 模型随机化/拼错参数名 → remap 回规范名
↓ ⑥ 工具名别名 "Read"/"Write"/"Bash"(Claude 风格)→ 映射到自家工具名
↓ ⑦ 强类型校验 serde 反序列化到工具的 Args 结构体 —— 最后拦截hashline 编辑器的 deserialize_edits 也在这一步:接受"数组 / 双编码 JSON 字符串 / 单对象"三种输入格式——注释直说"模型有时会给 edits 数组多包一层引号"。
为什么值得修?
每次修复成功,都能省下一整轮模型请求(重新生成可能上千 token,还可能再次出错)。这些规则是在处理模型反复出现的输出错误。
4.4.5 修复失败时把错误返回给模型
修不好时,不要让进程崩溃,也不要隐藏错误。把错误作为一条工具结果返回,让模型在下一轮改正。四家都这样做,但错误信息是否具体,会直接影响模型能否一次改对:
| 项目 | 回填内容 | 点评 |
|---|---|---|
| codex | RespondToModel("failed to parse function arguments: {err}"),置 needs_follow_up | 标准做法;parse 错误含行列位置 |
| dsh | { content: "Error: ...", isError: true },INVALID_ARGS 带字段路径;UNKNOWN_TOOL 带 reachableFrom 指引 | "工具可见但不可直呼"时告诉模型"请从 run_code 里调",防误判系统坏了 |
| grok | 错误原因 + arguments 原文前 2000 字节 + JSON 行列位置 + 修复指引 | 教科书:让模型改一个 typo,而不是从头重写千行参数 |
| pi | 未知工具或 schema 校验错误转成 isError 工具结果;执行前后还可由 hook 阻止或改写结果 | 核心保持通用,具体审批和业务约束交给 hook/extension |
grok 特意加了“原文”字段,因为从历史记录恢复时 arguments 会被脱敏成 {},不带原文模型就只能盲猜重写。 这样一条错误消息里塞了"哪里错、原文是什么、怎么改"三件事。
codex 进一步把错误分成两类:
text
RespondToModel(msg) → 作为工具结果回传,turn 继续(参数错、执行错、未知工具等可纠正错误)
Fatal(msg) → 直接终止 turn(仅"payload 与工具类型不匹配"这类程序性 bug)在 codex 的这条执行路径里,沙箱拒绝、审批拒绝、命令非零退出通常也能作为 RespondToModel 回给模型。但这不是所有产品都应继续执行的通用规则:审批策略可以在拒绝后结束当前 turn,用户取消和协议错误也应停止。
实现要明确每类拒绝是“允许模型换方案”还是“立即结束”,不能只看错误字符串猜测。
4.4.6 生成时限制格式,能不能替代执行前校验
社区有个看起来更彻底的思路:生成约束(以 OpenAI structured outputs 为代表)——在模型生成阶段就限制工具参数必须符合一部分 JSON Schema,让格式错误根本不发生。pi 允许按工具选用它;另外三家把它挡在了工具参数之外。为什么一个听起来应该全局开启的能力,大家用得这么克制:
- codex 的 function 工具使用
strict: false;严格结构化输出主要用于部分最终结果,而不是工具调用参数 - grok:strict 只用于 headless CLI 的最终文本输出、goal evaluator 等非工具场景;工具定义里根本没有 strict 字段
- dsh:同样不用
- pi:工具可以选择实验性的 constrained sampling(约束生成)。模型服务支持且 schema 能转换为严格子集时开启 strict;不支持时可以降级,也可以声明“必须 strict”并直接报错。无论是否开启,执行前仍做本地 schema 校验
前三家的共同选择是把参数约束放在生成后。pi 说明了另一种可维护做法:把 strict 当作每个工具、每个模型服务的可选能力,而不是全局开关。因为 strict 对 schema 有额外要求,复杂的引用、联合对象和条件 schema 未必能安全转换。
生成约束可以减少格式错误,本地校验仍是安全边界;两者不能互相替代。它是否提高某个模型的调用质量,应放进第 21 章的评估集实测。
4.4.7 自建时按顺序完成四项检查
自建时不必照抄任何一家。执行器可以按顺序完成四项检查:每一步只做一件事;可恢复的参数或执行错误生成工具结果,取消、协议损坏和程序内部错误则按停止策略处理。
| 环节 | 职责 | 失败时的动作 |
|---|---|---|
| 解析参数 | JSON.parse;顺带修复"双编码"(模型有时给 JSON 再包一层引号,先解包再检查结构) | 返回错误文本,附原文开头 120 字符让模型知道自己写了什么 |
| 结构检查 | 解析成功但不是对象(模型只输出了数字/字符串/数组) | 错误回传,告诉模型正确的结构({"参数名": 值}) |
| 查注册表 | 幻觉工具名 → 对照注册表;schema 校验缺参/类型错 | 错误回传,附可用工具清单或缺哪个字段(schema 需要哪些) |
| 执行工具 | 真正执行工具 | 执行异常同样回传而不是抛出(模型能看到 stack 摘要并调整) |
每个错误消息都要包含三项信息:哪里错、模型给出的原文是什么、应该怎样修改。例如模型错误地调用 readfile 时,收到包含可用工具名的错误后,通常能自行改成 read_file 并重试。这里没有神秘机制,关键就是错误信息足够具体。
这一步有现成的 TS 参照:dsh 的 ReactLoopAgent 分别使用流式拼装(BlockAssembler)、工具参数校验与错误回传(INVALID_ARGS 带字段路径、UNKNOWN_TOOL 带 reachableFrom 指引)实现上述环节——源码入口见附录 B。
4.5 第四步:处理并发、取消和重复副作用
4.4 解决的是“单个调用能不能执行”。剩下三类故障发生在调用之间或执行之后:同一批调用互相影响,用户取消后后台仍运行,以及工具已经成功却因结果丢失被再次执行。对应要处理三件事:
- 多个调用能否同时执行;
- 用户按下 Esc 后怎样停止;
- 重试会不会重复产生副作用。
最简循环没有覆盖这些情况,但生产实现必须明确处理。
4.5.1 多个工具调用什么时候可以并行
先看 API 格式:一条 assistant 消息可以携带多个 tool_calls,但这只说明它们出现在同一条消息里,不表示彼此没有依赖。是否可并行,必须由系统根据工具的并发安全声明、调用参数或已有计划来判断:
json
{ "role": "assistant", "tool_calls": [
{ "id": "c1", "function": { "name": "read_file", "arguments": "{\"path\":\"a.ts\"}" } },
{ "id": "c2", "function": { "name": "read_file", "arguments": "{\"path\":\"b.ts\"}" } },
{ "id": "c3", "function": { "name": "run_tests", "arguments": "{}" } }
] }但"同一响应出现多个调用"不代表系统可以直接对所有调用执行 Promise.all。还要处理三个问题:
① 谁真正知道工具能不能并发? 模型不知道 run_tests 会写临时文件,而两个 read_file 只读,所以工具定义必须声明并发安全性。dsh 为每个工具实现 isConcurrencySafe():安全调用进入并发数量受 maxParallelToolCalls 限制的执行队列,不安全调用单独执行。没有声明时默认不安全(fail-safe,即无法判断时采用更安全的选择)。
pi 采用更小的接口:Agent 默认并行执行一批工具,但任何工具都可以声明 sequential,会让整批改为串行。文件工具还在更靠近副作用的位置增加一道保护:不同文件可以并行,同一个真实路径的修改进入同一队列串行执行。这说明“模型调用批次是否并行”和“某类资源是否需要互斥”是两层决策。
② 结果顺序。 并行完成顺序会乱序。dsh 选择“并行执行、按模型发起顺序提交”,这样更容易阅读也能保持稳定组装;但只要协议允许且每个结果都用 id 正确配对,也可以按完成顺序返回。不要把“按模型序”写成所有模型接口的要求。
③ 一个调用失败时影响谁。 如果已经确认 c1/c2/c3 互不依赖,c2 读取的文件不存在时可以只报告 c2 失败,让其他调用照常完成。如果后续调用依赖前一个结果,则应停止或标记为“因依赖失败未执行”。
实现并行执行器时要保证三件事:
- 只并行安全且无依赖的调用
- 用调用 id 正确配对结果
- 为每个调用记录独立状态
结果可以按模型发起顺序组装,也可以在协议允许时按完成顺序交付。dsh 用 maxParallelToolCalls 限制安全调用的并发数量,不安全调用单独执行,最后按模型给出的顺序提交结果。
4.5.2 用户按下 Esc 后怎样停止并记录本轮任务
用户看到 Agent 跑偏,按下 Esc。此刻状态是:模型可能还在流式输出、两个工具正在执行、还有三个调用排队。中断不是"杀进程"这么简单,它要回答四个问题:
text
① 正在执行的工具怎么办? 大多无法撤销(文件已写、命令已跑)→ 让它跑完或安全终止
② 排队未启动的调用怎么办? dsh:补"合成错误结果"(未启动=被中止),保证日志可重放——
没有 c2 的结果,重放时上下文就是残缺的,模型会困惑
③ 对话历史怎么记? 中断的 turn 也要有完整的边界事件(dsh 仍会把 turn/end 写入日志;
codex 有专门的 turn_aborted 上下文片段)
④ 下一轮模型看到什么? codex 注入 turn_aborted 提醒——模型知道自己被打断在哪,
恢复时不会装作无事发生最小实现需要两个检查点:每轮模型调用前检查一次中止信号,每个工具启动前再检查一次。这样不会在工具代码执行到一半时强行修改循环状态;被跳过的调用要补一条"未执行:用户中断"的结果,保证上下文完整。
生产实现还必须把同一个中止信号传进模型请求、工具执行、子进程和重试等待计时器,并为每个调用记录"排队/已启动/已完成"。否则 UI 虽然显示已取消,后台副作用仍可能继续发生。
4.5.3 幂等:重试会把副作用做两遍吗
第 19 章会讲网络错误的指数退避重试。重试有一个重要前提:从外部状态看,重新发起 LLM 请求通常不直接产生工具副作用,但会重复计费、产生不同文本,服务端也可能已经接收第一次请求;工具调用则还可能改变外部状态。因此两类调用都需要 attempt id、预算和明确的重试条件。
text
危险场景:
① 工具执行成功(文件已写)
② 回填结果前,进程崩溃 / 网络断
③ 重启后重放:工具再执行一次 → 副作用 ×2
(对 edit_file:再执行一次会因"old_string 找不到"而失败——这是工具碰巧识别出了重复执行,
不是幂等性本身;真正幂等的例子是 write_file 写入相同内容——结果不变)三种应对,按投入排序:
- 执行副作用前写入持久化记录(dsh 的答案):先把
tool/call写入日志,再执行工具;完成后写入tool/result。恢复时若结果已在日志里,只读取记录、不重新执行;若只有启动记录而没有结果,状态是 outcome unknown(结果未知),必须查询外部状态,不能假设工具没有执行 - 幂等化工具:edit_file 的唯一性校验会让第二次重放失败,但文件的最终状态不再变;write_file 覆盖同样内容通常也能保持最终状态。真正危险的是"追加型"操作(发请求、建资源),这类工具应该带幂等键,而且接收方必须真正按键去重。后台 jobId 可以用于跟踪,但本身不能证明已做幂等去重
- check-then-act(先检查再执行):重试前先查状态,例如"这个文件已经改过了吗?"
判断口诀
只读工具通常没有写副作用,但重试仍会消耗费用和限流额度,结果也可能因时间变化;写工具应尽量幂等(相同输入→相同最终状态);外部副作用工具使用幂等键时,接收方必须真的按键去重。
4.5.4 每次重试的输出必须分开记录
流式场景还有一个前端很容易踩的坑:第一次模型请求已经显示了半段文字,随后断流并重试。如果 UI 把第二次输出继续追加到同一个字符串,用户看到的是两次不同生成结果拼在一起。
每次模型请求或重试都应记录为独立的 attempt(尝试),携带自己的 attemptId 与状态(streaming / completed / failed / interrupted),并与用户看到的同一轮任务 turnId 分开。
规则只有三条:
- 每个增量片段(delta)必须带
attemptId; - 失败 attempt 的部分输出可以留在 UI 中并标记"已中断",但不能进入最终模型历史;
- 只有收到该 attempt 的完整结束事件后,才提交最终 assistant message。
4.5.5 异步输入何时进入循环
真实会话运行时,用户会追加要求,后台任务会完成,系统也可能撤销权限。不要让这些事件直接修改正在组装的 messages 数组;先放入 Inbox(输入队列)。每个事件带一个用于持久化去重的 id、来源(user / tool / job / system)和处理类别(lane),再在明确的时机读取:
| lane | 何时消费 | 典型事件 |
|---|---|---|
interrupt | 立即触发当前 AbortSignal,随后在安全点记录结果 | 用户停止、权限撤销 |
next-step | 当前模型响应或工具批次结束后、下一次模型调用前 | steer 消息、后台 job 完成 |
next-turn | 当前 turn 正常收尾后 | 不要求打断的后续任务 |
Inbox 入队要先写入日志再确认;同一 lane 内按日志顺序读取,并用 id 去重。事件一旦进入模型上下文,也必须留下可重放记录。
内容分类不能改变权限:来自网页、工具输出或子 Agent 的文字即使写着"立即执行",仍只是数据。这里定义的是异步输入什么时候可以进入循环;attempt 事件、断线重放游标、UI reducer 和控制接口留到第 20 章。
pi 把两个常见时机直接做成 Agent API:steering 消息在当前 assistant turn 结束后、下一次模型调用前进入;follow-up 消息在 Agent 原本准备结束时再开启下一轮。两类队列都可以一次取一条或一次取全部。这是上表 next-step / next-turn 的一个小型实现;持久化、优先级和外部事件去重仍要由更上层 Harness 补齐。
【对比小结】
| 最简循环(不到十行伪代码) | pi(TS) | dsh(TS) | codex / grok-build(Rust) | |
|---|---|---|---|---|
| 循环结构 | for + messages 数组 | Agent + 消息数组 + steer/follow-up 队列 | 只追加事件日志,由 deriveMessages 生成消息 | 事件触发处理 + chat state 对象 |
| 工具执行 | 串行 await | 默认并行;工具可要求整批串行;同文件写队列互斥 | 并发安全声明 + 有数量上限的并行队列 | 各自有流式启动、并发和错误记录策略 |
| 可中断 | 否 | AbortSignal + steer/follow-up | Inbox(steer/inject) | 是(steer / interrupt) |
| 崩溃恢复 | 无 | 上层会话树/Harness 负责 | 保留完整尾部并补合成结束事件 | rollout 重建 / updates.jsonl 重放 |
最简循环追求可读、可教;生产实现还要保证速度、稳定性和完整运行记录。它们使用同一个循环,差别在外围的校验、并发、取消和恢复。
pi 适合先看最简循环怎样加校验、并行和消息队列;dsh 再展示怎样把循环接到事件日志与插件体系。源码入口统一见附录 B。
本章小结
- 第 3 章的循环不变;本章补的是接口契约、参数校验、并发、取消和异步输入时机。
- 不完整输出不能执行。完整参数也必须经过本地强校验,失败时把位置、原文和修复方法返回给模型。
- 外部消息先写入 Inbox,再按 interrupt、next-step 或 next-turn 的时机进入循环。
现在循环已经知道何时继续、何时停止,也知道不能直接相信模型参数。但“执行工具”仍只是流程图中的一个节点。下一章把它展开,说明一次工具调用怎样真正完成;MCP、沙箱和多 Agent 都会复用这段过程。