Skip to content

第 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 章再把检查放进完整的工具执行过程。

从 token 到执行的四个校验环节与错误回传

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 修复失败时把错误返回给模型 ​

修不好时,不要让进程崩溃,也不要隐藏错误。把错误作为一条工具结果返回,让模型在下一轮改正。四家都这样做,但错误信息是否具体,会直接影响模型能否一次改对:

项目回填内容点评
codexRespondToModel("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 写入相同内容——结果不变)

三种应对,按投入排序:

  1. 执行副作用前写入持久化记录(dsh 的答案):先把 tool/call 写入日志,再执行工具;完成后写入 tool/result。恢复时若结果已在日志里,只读取记录、不重新执行;若只有启动记录而没有结果,状态是 outcome unknown(结果未知),必须查询外部状态,不能假设工具没有执行
  2. 幂等化工具:edit_file 的唯一性校验会让第二次重放失败,但文件的最终状态不再变;write_file 覆盖同样内容通常也能保持最终状态。真正危险的是"追加型"操作(发请求、建资源),这类工具应该带幂等键,而且接收方必须真正按键去重。后台 jobId 可以用于跟踪,但本身不能证明已做幂等去重
  3. check-then-act(先检查再执行):重试前先查状态,例如"这个文件已经改过了吗?"

判断口诀

只读工具通常没有写副作用,但重试仍会消耗费用和限流额度,结果也可能因时间变化;写工具应尽量幂等(相同输入→相同最终状态);外部副作用工具使用幂等键时,接收方必须真的按键去重。

4.5.4 每次重试的输出必须分开记录 ​

流式场景还有一个前端很容易踩的坑:第一次模型请求已经显示了半段文字,随后断流并重试。如果 UI 把第二次输出继续追加到同一个字符串,用户看到的是两次不同生成结果拼在一起。

每次模型请求或重试都应记录为独立的 attempt(尝试),携带自己的 attemptId 与状态(streaming / completed / failed / interrupted),并与用户看到的同一轮任务 turnId 分开。

规则只有三条:

  1. 每个增量片段(delta)必须带 attemptId;
  2. 失败 attempt 的部分输出可以留在 UI 中并标记"已中断",但不能进入最终模型历史;
  3. 只有收到该 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-upInbox(steer/inject)是(steer / interrupt)
崩溃恢复无上层会话树/Harness 负责保留完整尾部并补合成结束事件rollout 重建 / updates.jsonl 重放

最简循环追求可读、可教;生产实现还要保证速度、稳定性和完整运行记录。它们使用同一个循环,差别在外围的校验、并发、取消和恢复。

pi 适合先看最简循环怎样加校验、并行和消息队列;dsh 再展示怎样把循环接到事件日志与插件体系。源码入口统一见附录 B。

本章小结 ​

  • 第 3 章的循环不变;本章补的是接口契约、参数校验、并发、取消和异步输入时机。
  • 不完整输出不能执行。完整参数也必须经过本地强校验,失败时把位置、原文和修复方法返回给模型。
  • 外部消息先写入 Inbox,再按 interrupt、next-step 或 next-turn 的时机进入循环。

现在循环已经知道何时继续、何时停止,也知道不能直接相信模型参数。但“执行工具”仍只是流程图中的一个节点。下一章把它展开,说明一次工具调用怎样真正完成;MCP、沙箱和多 Agent 都会复用这段过程。

基于 MIT 协议发布,欢迎转载与贡献。