Agent 构建指南:研发要素全景¶
本章不对应原站文章,是知识库的补充章节(独立原创):把第 2 部分拆过的零件和开源 Agent 的做法串成一张全景图。
一句话结论:一个生产级 Agent = 一个「调模型 → 用工具 → 再调模型」的循环,加上围绕它的五层工程模块和一条贯穿各层的治理线。循环决定 Agent 能做什么;模块决定它能不能在多用户、多实例、随时会出错的环境里可靠地做完。先把循环跑通,再按风险逐层补模块。
拆了这么多零件,自己造一个时从哪下手?¶
前面几章把 Claude Code 和 Codex 拆得很细:System Prompt、文件读写、上下文压缩、记忆、权限检查、Plan Mode、Harness。
你可能会问:如果明天要我从零做一个 Agent 产品,这些零件哪个先做、哪个可以晚点做?有没有漏掉的?
拆解是按「这个工具有什么」组织的,造东西要按「请求经过哪些层、每层会出什么事故」组织。这一章换个角度,把零件重新排一遍。依据有两类:公开的工程文章,以及 9 个开源仓库的源码(以 Codex、OpenHands、Cline、opencode、Cloudflare Agents 等 Agent 框架为主,另有续传库和沙箱平台)。文中的「常见的坑」是做 Agent 时很容易踩到的通用问题。
全景:五层模块加一条治理线¶
先看全貌。五层从上往下是一次请求经过的层次;单独的那一组「治理线」不属于某一层,而是横跨所有层。
加粗的「Agent 循环」是唯一一个所有 Agent 都必须有的模块,其余模块按风险逐步补。哪些现在就要、哪些可以晚做:
| 模块 | 它解决的问题 | 原型阶段 | 上线前 |
|---|---|---|---|
| Agent 循环 | 让模型连续调用工具直到完成,并在该停的时候停下 | 必须 | 必须 |
| 工具与闸门 | 模型能做什么,哪些事绝对不能做 | 必须 | 必须 |
| 提示词装配与缓存 | 每轮给模型看什么,成本和延迟能不能接受 | 简单版 | 必须 |
| 历史压缩 / 记忆 | 对话变长后不超窗口、不丢关键信息 | 可以晚做 | 长对话必须 |
| 多 Agent 编排 | 任务太重或需要隔离时分给执行者 | 先不做 | 按需 |
| 执行模型 | 同一会话不并发乱写;中途来新消息、取消、崩溃都有确定行为 | 单进程可省 | 多实例必须 |
| 沙箱与出网代理 | 执行任意代码而不泄露凭据、不被注入攻击利用 | 执行代码就要 | 必须 |
| 交互协议与续传 | 前端稳定展示流式内容、任务进度和交互卡,断线不乱 | 简单版 | 必须 |
| 状态与持久化 | 记录能查、能恢复,热数据快、冷数据全 | 简单版 | 必须 |
| 治理线 | 出问题能定位,改提示词能评估,发布能回滚 | 先接 trace | 必须 |
回到主线:这张图里除了循环,每个模块都在回答同一个问题:在有限的上下文和不可靠的运行环境里,怎么让模型「看到的」是对的、「做出的」是安全的、「说出去的」能可靠送达。
下文按「内核 → 工程化 → 治理」展开:内核(循环、工具、上下文)决定 Agent 聪不聪明,工程化(编排、执行模型、安全、交付)决定它可不可靠。状态层没有单独成节,它的内容分散在执行模型和交付两节里。
内核一:循环最难的是什么时候停¶
第 3 部分的 Coding Agent 已经写过循环本身,十几行代码。生产问题几乎都出在终止和中断上。
几条做法:
- 设三道上限,各管一件事。 迭代次数(例如几十圈)防死循环;软预算(例如十来分钟)只拦「再开一圈」,不打断正在进行的调用;硬超时(再长一些)兜底取消一切。三者分开,体验和资源保护才能兼顾。
- 撞上限时收口,而不是报错。 到达迭代上限后再调一次模型,但禁止它再用工具(
tool_choice设为none),让它用现有信息写总结,并把结束原因(如max_iterations)告诉调用方。用户得到的是「做到哪了」,而不是一条错误。 - 半截工具调用必须收拾。 被打断时,已经发出但没拿到结果的工具调用会让下一轮的历史不合法。两种主流做法:Codex 给它补一条合成的
"aborted"结果,Cline 把不完整的调用整段丢掉。选哪种都行,但必须选一种。 - 先落盘,再发「完成」。 否则前端一刷新,刚才那轮就不见了。
- 流式文本要有权威全文校准。 逐段下发的文字可能丢片段,也可能被服务端事后清理(去掉半截链接、重试残留)。轮结束时下发一份权威全文让前端整段替换;没下发时才以拼接结果为准。
- 工具失败也要返回结果。 把错误写成模型读得懂的结果回填,让它决定重试或换路。但依赖故障(超时、权限、解析失败)不能伪装成「没找到」,否则模型会基于假事实继续推理。
说穿了,循环本身就是一个 while,难的是给这个 while 写好每一个 break。章末的「动手」把其中三个 break 写成了可以跑的代码。
内核二:工具描述是软约束,硬约束要写在闸门里¶
Harness 那一章讲过两只手:prompt 立规矩,代码设护栏。落到工具上就是这几条:
- 每个动作只列允许的参数。 一个工具有多个动作时(比如「创建 / 追加 / 中断 / 查询」),在描述里逐个动作写清「只能填哪些字段」。多余字段会被模型乱填。
- ID 只能抄,不能编。 需要 ID 的参数,描述里写明「必须从之前的工具结果里复制」,服务端再校验存在性。模型编造 ID 是最常见的工具误用之一。
- 用代码闸门代替提示词禁令。 比如「只有用户能叫停后台任务」这类规则:与其在提示词里写「不要自己停任务」,不如在工具执行时检查这次请求是不是用户发起的,不符合就拒绝并返回原因。
- 工具定义保持稳定。 工具定义在缓存前缀里,按轮改动会让缓存每轮失效。随轮变化的信息(比如当前有哪些后台任务)放进一段临时消息,而不是改工具 schema。
- 服务端兜底去重。 模型偶尔会把完全相同的请求连发两遍。服务端对重复请求做幂等去重,告诉模型「已经在处理」,而不是再执行一次。
- 技能 = 提示 + 脚本 + 依赖。 可复用能力打包成技能按需加载(见 Skills);外部系统优先走 MCP 这类标准协议。
常见的坑
假设你在一条拒绝结果的末尾催一句「现在去做某事」,模型很可能把它理解成「还需要再调一次工具」,于是开始重复调用。工具返回值里的说明文字会显著影响模型的下一步。改这类文字之前,先用固定语料重放几十次、统计行为分布,再决定上不上线(方法见下文「治理」)。
内核三:Agent 不靠内存记事,每轮从账本重建上下文¶
进程会重启,请求会落到别的实例上。能跨轮延续的只有持久化的记录。所以每一轮都要重新拼一次上下文,顺序按「越稳定越靠前」:
运行时注入放在本轮用户消息之后、不写进历史:跨轮的公共前缀不受它影响,同一轮后续几圈还能缓存到它后面。这和 System Prompt 那一章里「稳定段在前」是同一个道理。
上下文变长后有三种手段,代价不同:
| 手段 | 做法 | 代价 | 适合 |
|---|---|---|---|
| 清理旧工具结果 | 处理过的工具输出换成占位,保留调用记录 | 需要时得重新取 | 输出大、可重取(搜索、读文件) |
| 摘要压缩 | 旧对话写成摘要页,保留近期原文 | 细节可能丢,压太狠会丢关键上下文 | 长对话的主手段(见上下文压缩) |
| 外部记忆 | 信息写到窗口外,需要时检索 | 多一次检索,可能检索不到 | 跨会话的偏好和长期事实(见自动记忆、mem0) |
压缩时必须保护什么?开源实现普遍保护三类:最近 N token 的原文(Cline、opencode)、工具调用与结果的配对完整性(OpenHands 的切点不拆工具对)、用户消息。多 Agent 系统还要额外保护派发记录(子任务 ID 和指令):压掉了,编排者就找不回正在跑的执行者。
还有一点:提示词按配置发布,不跟代码走。放在配置中心,按环境灰度、可回滚。代价是「代码没变,行为变了」,所以每次改动要记版本,并能从 trace 里查到当轮用的是哪一版。
常见的坑
假设压缩策略只保护了派发记录,却没管执行者回来的结果消息,长对话里就会出现「任务明明做完了,Agent 说不出结果」。设计压缩策略时,要逐类列出「哪些消息丢了会导致错误行为」,而不是只按新旧切。
工程化一:多 Agent 只在能拆开时划算¶
你天天用 Sub Agent,但什么时候该把一个 Agent 拆成多个?Anthropic 和 Cognition 的观点看似相反,其实划出了同一条边界:
| 适合拆 | 不适合拆 | |
|---|---|---|
| 任务形态 | 可以并行探索、互相依赖少(多方向调研);或需要隔离环境执行(写代码、生成文件) | 多个部分要共享大量上下文、共同做设计决策 |
| 风险 | 编排者要写清任务描述,否则执行者会做偏 | 并行的执行者各自做出冲突的隐含决定,最后拼不起来 |
| 代表观点 | Anthropic:研究类任务用编排者 + 并行子 Agent,内部评测上比单 Agent 高 90.2%,尤其是广度优先的查询;它也承认共享上下文、依赖多的任务不适合 | Cognition:共享决策的任务,多 Agent 太脆弱,宁可单 Agent |
拆了之后,派发和结果回流通常是异步的:
几条做法:
- 派发指令把三样东西分开写:用户要求做什么(每句都能追溯到用户原话)、之前已经确认的背景事实和资源 ID、编排者自己的建议(标明仅供参考)。建议和要求混在一起,执行者会把建议当成用户要求。
- 追加指令按 ID 寻址。开源框架的主流做法都是让编排者的模型按 ID 指定目标:Codex 的
send_message(target)、opencode 续用子会话 ID、OpenHands 的resume: task_id。系统不替模型猜目标;分不清时先查状态,仍不清楚就问用户。(OpenAI Agents SDK 的 handoff 是另一回事:它按 agent 名把控制权整个移交出去,不是给已在跑的执行者追加指令。) - 同一主题复用,不可恢复才新建。补充、修改、追加都发给原执行者,新开的执行者看不到之前做过的工作。用户主动停掉的执行者也可以继续用。
- 每轮注入任务名单,少查询。把「有哪些执行者、各自什么状态、有没有待回答的问题」作为运行时信息注入,模型就不必每次先查状态。
- 产品层给用户一个明确指定目标的入口。让用户在任务卡上直接回复,客户端把任务 ID 一起发上来;有明确目标就直接投递,没有再交给模型判断。
- 结果回来要能「不开口」。心跳、对停止指令的确认、状态变化只更新任务卡,只有需要自然语言汇报的事件才让编排者开新一轮。
工程化二:同一会话同一时刻只能有一个执行体¶
多实例部署后,两个执行体并发写同一份对话记录、同一个请求被执行两次,都是迟早会发生的事故。三种做法:
| 做法 | 原理 | 代表 | 取舍 |
|---|---|---|---|
| 单实例 actor | 按会话 ID 固定路由到唯一实例,实例内单线程 | Cloudflare Agents(Durable Objects);单进程内加锁(Codex) | 最简单;靠平台保证单实例,或只适用单进程 |
| 租约 + fencing | 执行前抢带过期时间的租约,每次接管递增代际编号;写入时校验编号,拒绝旧执行体 | OpenHands agent-server 的会话租约 | 适合多实例;要处理续约、接管和写入校验 |
| 会话收件箱 | 会触发执行的请求先进会话队列,再按顺序逐个执行 | opencode 的会话 inbox(持久化队列 + 进程内单会话协调器);多实例时可以用消息队列 + 租约自己搭 | 能统一排序和去重;复杂度最高 |
说白了:actor 是「一个会话只归一个单线程实例处理」;fencing 是给每一任执行者发一个递增的「任期号」,旧任期的写入一律拒收,这样接管之后,旧执行者就算还活着也写不进去。
配套的几条原则:
- 幂等键从上游身份派生:客户端消息 ID、「定时任务 + 触发时刻」、上游通知 ID,再加类型前缀防撞号。不要在入队时随机生成,上游重投时随机 ID 会让去重失效。
- 宁可不执行,也不重复执行有副作用的动作:拿不到执行权就不执行;崩溃后不确定某个有副作用的动作是否已经发生,就不重放;收下的请求可以失败,但必须留账。
- 取消走控制通道,不排队:广播取消信号让执行体立即停下,同时把取消请求持久化下来让执行体定期核对,作为广播丢失时的兜底。
- 无人在场的轮可以设为不可打断:比如定时任务,新消息排在它后面。是否可打断由服务端判定,不交给请求方决定。
执行中又来一条消息怎么办?业内有五种策略,其中 steer 指「不打断,在下一个步骤边界把新消息塞进当前轮」:
| 策略 | 行为 | 代表实现 | 适合 |
|---|---|---|---|
| reject | 拒收新消息 | LangGraph | 不允许并发输入的严格流程 |
| enqueue | 当前轮跑完,新消息作为下一轮 | LangGraph 默认;Cloudflare Agents 默认 queue | 不能被打断的轮 |
| interrupt | 停下当前轮、保留已产出部分,接着处理新消息 | LangGraph | 对话型编排者:用户改主意要立刻生效 |
| rollback | 撤销当前轮(包括原输入),新消息当首条请求 | LangGraph | 原请求作废 |
| steer | 不打断,在下一个步骤边界把新消息注入当前轮 | Codex、opencode(默认)、OpenHands;Cline 的 steer 会先中止进行中的模型输出,已在跑的工具照常完成 | 长任务中途纠偏,且你能控制循环本身 |
常见的坑
steer 要求你掌控 Agent 循环。假设执行者包装的是一个你改不了内部逻辑的现成 Agent,你就无法确定它会在什么时机吸收新消息,注入的消息可能和它的收尾撞在一起,最后没人处理。这种情况下更稳的做法是只排队,等它空闲再把补充作为新一轮发送;需要立即生效时,用「先打断、再把补充当新指令发」替代 steer。无论哪种,都要让用户看到「有补充在排队」,并能撤回。
工程化三:真凭据不进沙箱,出网默认拒绝¶
提示词注入无法根除,能限制的是 Agent 被骗之后能做到什么。权限检查管的是「这个动作要不要放行」,沙箱管的是「就算放行了,最坏能坏到哪」。
沙箱里的代码即使被注入攻击完全控制,也拿不到真凭据、发不到白名单以外的地方。两种主流做法:
| 流派 | 做法 | 代表 | 注意 |
|---|---|---|---|
| 出口注入凭据 | 沙箱内只有占位值,代理转发时替换成真凭据或加鉴权头 | Codex 的 network-proxy(占位值替换);OpenAI Agents SDK 的 Vercel 沙箱适配器(透传平台的出站 header 注入规则);E2B 也支持按规则给出站请求加 header | 最安全;代理成为关键依赖 |
| 沙箱内短期凭据 | 沙箱持有短期、收窄权限的令牌,配合平台层域名白名单 | OpenHands(可选:托管 key 被轮换、遇到 401 时重新拉取一次);E2B 的出网 allowOut / denyOut(注意默认全放行) | 实现简单;令牌能被沙箱内代码读到,靠权限范围和有效期控制损失 |
另外两点:
- 高风险动作要人确认,而且是异步的。 Agent 弹出确认卡后立即结束本轮,用户的回答作为新输入进来,带上卡片 ID 对应到具体请求。不要让一轮在原地同步等用户,否则长时间占着执行权。需要长时间等待时,可以参考 Temporal 的 Signal:等待期间不占计算资源。
- 列出已知残留风险。 域名白名单不可能堵住所有外泄路径。上线时写清已知的残留风险和对应的监测手段,比宣称「绝对安全」可靠。
工程化四:前端拿到的是三类帧,各有自己的可靠性约定¶
做过前端的人对这一块最熟。流式响应讲了怎么流,生产里还要把帧分类、约定好每类的投递语义:
| 帧类别 | 例子 | 投递约定 | 前端怎么处理 |
|---|---|---|---|
| 对话流 | 文字片段、轮结束 | 片段可能丢;轮结束可带权威全文 | 按轮号拼接;轮结束带了权威全文就整段替换 |
| 任务实体 | 任务卡快照、状态、产物 | 至少一次,可能重复 | 整实体替换,只接受更大的版本号 |
| 交互卡 | 提问、审批 | 带卡片 ID,状态变化单独下发 | 回答时带回卡片 ID;已处理的卡置灰 |
| 轻提示 | 一次性 toast | 不进历史、不回放,用户不在线就丢 | 只展示,不存状态 |
几条约定:
- 轮号是唯一时钟。 每条用户输入由服务端分配递增轮号,下行帧都带上它;出现更大的轮号,旧一轮就不会再续写。
- 能力协商代替服务端开关。 客户端在会话开始时声明自己支持的协议能力,服务端按声明决定下发什么格式。如果按账号或环境开关切换协议,未升级的客户端可能无法渲染新格式。
- 续传靠持久化 + 游标。 客户端重连时带上「收到哪了」,服务端只补差量。游标的形式各家不同:Cloudflare Agents 存 SQLite 按整轮重放,OpenHands 按时间戳补发,LangGraph 用
Last-Event-ID,Vercel 的 resumable-stream 按字符偏移。 - 快照优于逐条重放。 如果前端只会追加渲染,整批重放旧片段会重复显示。更稳的是持久化「这一轮到目前为止的完整文本 + 序号」快照,重连时一次给出,前端整体替换。
- 多端一致。 同一会话可能同时开在多台设备上:一端的输入要同步给其他端,一端的停止要能停到其他端正在跑的轮。所以输出先发到共享频道,再由各连接所在的实例转发。
- 冷热分层与单写者。 实时数据先写热存储(快,有过期时间),由唯一的写入方异步投影到冷存储(全,长期保存)。代价是历史接口的数据会稍晚于实时推送,界面以实时推送为准,稍后再从历史补齐。
治理:行为会在不改代码时变化¶
模型、提示词、工具描述,任何一处变化都会改变 Agent 的行为。没有回放评测,就不知道改动是好是坏。(可观测的基础见 Agent 可观测性。)
- 每轮一个 trace ID,下发给前端。 模型调用、工具调用、耗时、用量串成一条 trace,在轮结束帧里把 ID 带给前端。用户报问题时拿着 ID 就能定位。
- 回放评测驱动提示词改动。 取一批真实上下文做固定语料,新旧版本各重放几十次,统计关键行为的分布(误派发、重复调用、空回复),差异显著再上线。比「看几个例子觉得好了」可靠得多。完整的评测体系见下一章。
- 黄金快照锁住关键输入。 把拼装后的系统提示词和工具定义做成快照测试,无意的改动在 CI 里就会暴露。
- 开关当回滚闸,并定期毕业。 新行为挂在开关后面灰度,开关同时是事故时的回滚手段。稳定后删掉开关,否则配置组合会越积越多。各环境的开关实际值要可查。
- 执行环境与服务分开版本化。 沙箱镜像、执行者运行时往往单独发布,要能回答「这个环境此刻跑的是哪个版本的执行者」。
- 指标看这几类:每轮迭代数分布、撞上限比例、工具错误率、重试次数、排队等待时间、取消与超时次数、上下文长度与压缩次数、缓存命中率。
研发路线:六个阶段¶
先证明 Agent 能把任务做对,再证明它能在生产里做稳。
- 定义任务与评测集:写清楚要做哪些任务、不做哪些,收集 30~100 条真实请求作为评测集,定义「做对」怎么判。完成标准:评测集和判分方式能自动跑。
- 单 Agent 循环 + 少量工具:实现循环和三道上限,接 3~5 个最关键的工具,接上 trace。完成标准:评测通过率达到可用水平,每轮能在 trace 里看清。
- 上下文与提示词工程:分槽装配、稳定前缀、压缩,提示词移入配置中心。完成标准:长对话不超窗口、不丢关键信息,改提示词有回放评测。
- 沙箱与能力扩展:要执行代码或生成文件时引入沙箱和出网代理,能力打包成技能。完成标准:沙箱内零长期凭据,出网默认拒绝,被拒请求可审计。
- 按需拆分多 Agent:只有任务可并行或需要隔离执行时才拆。完成标准:派发、追加、停止、结果回流四条路径都有测试,误派发率可度量。
- 生产化:单会话串行与幂等、中途输入策略、取消通道、人工确认、交互协议与续传、冷热存储、开关与灰度。完成标准:多实例压测下无并发写和重复执行,断线重连不重复、不丢内容,每个开关都有回滚预案。
第 4 部分的 mini Claude Code 基本就是在单进程里把阶段 2~5 走一遍;阶段 6 的大部分问题,只有多实例部署之后才会真正出现。
上线前检查清单(展开)
循环与工具
- 迭代上限、软预算、硬超时都设了,撞上限会收口总结
- 被打断时半截工具调用会补齐或删除
- 先落盘再发「轮结束」
- 工具的 ID 参数都由服务端校验存在性
- 禁止性规则在代码闸门里,而不只在提示词里
- 依赖故障不会被当成「没找到」
上下文
- 提示词稳定前缀可缓存,变化信息在末尾
- 压缩前逐类列出了「丢了会出错」的消息,并都受到保护
- trace 里能查到当轮提示词版本
编排与执行
- 同一会话不会有两个执行体同时写
- 请求有确定性幂等键,重投不会重复执行
- 中途来新消息的策略按轮类型明确
- 停止指令不排队,有持久化兜底
- 追加指令按 ID 投递给原执行者
- 用户能看到并撤回排队中的补充
安全
- 沙箱内没有长期凭据
- 出网默认拒绝,白名单有审计
- 高风险动作需要人工确认,且确认是异步的
- 已知残留风险写进了文档
交付与治理
- 前端以轮号判断最新轮,以权威全文校准
- 任务实体按版本号收敛,可重复投递
- 断线重连按游标或快照补齐,不会重复渲染
- 每轮 trace ID 下发到前端
- 提示词改动走回放评测
- 每个新行为都有开关和回滚预案,各环境开关值可查
动手:给循环写好三个 break¶
全景图里的模块大多要一整套基础设施才能演示,只有「内核一」能用几十行代码讲清楚。这一节在第 1 章的 runAgent 上加三件事:撞迭代上限时收口、软预算到点时不再开新一圈、被打断时补齐半截工具调用。全部跑离线 fake 模型,不需要 API key。
先看效果。场景一,模型一直想再搜一次日志,迭代上限设成 3 圈:
$ cd code/agent-node && node loop_guard/guarded_loop.ts
—— 场景一:撞迭代上限 ——
❯ 构建为什么突然变慢了?
⏺ search_logs({"query":"slow"})
✔ build took 412s (baseline 95s)
⏺ search_logs({"query":"cache miss"})
✔ restore cache: 0/38 hits
⏺ search_logs({"query":"dependency install"})
✔ install deps: 301s
⏺ 已确认:依赖安装阶段缓存全部未命中,占了大部分耗时。还没查清缓存为什么失效,建议下一步对比两次构建的 lockfile。
[结束原因 max_iterations,3 圈]
[离线 fake 模型] 收口那次请求的 tool_choice:{"type":"none"}
$ cd code/agent && uv run python -m loop_guard.guarded_loop
—— 场景一:撞迭代上限 ——
❯ 构建为什么突然变慢了?
⏺ search_logs({"query": "slow"})
✔ build took 412s (baseline 95s)
⏺ search_logs({"query": "cache miss"})
✔ restore cache: 0/38 hits
⏺ search_logs({"query": "dependency install"})
✔ install deps: 301s
⏺ 已确认:依赖安装阶段缓存全部未命中,占了大部分耗时。还没查清缓存为什么失效,建议下一步对比两次构建的 lockfile。
[结束原因 max_iterations,3 圈]
[离线 fake 模型] 收口那次请求的 tool_choice:{'type': 'none'}
第 1 章的 runAgent 到这里会抛出「超过 3 轮仍未结束」,前面三次搜索的结论全丢了。现在用户拿到的是一段「查到了什么、还差什么」的总结,调用方还能根据 max_iterations 在界面上标一句「已达上限」。
场景二,模型在同一圈里要了两个工具,第一个跑着的时候用户按了打断,接着补了一句话:
倒数第三行是对照组:如果打断时直接退出循环,两个 tool_use 都没有对应的 tool_result,下一次请求就是不合法的历史,接口会拒绝。danglingToolUses 就是照着这条规则写的检查函数。
核心代码只多了两段。第一段是收口:
const wrapUp = async (stop: StopReason, why: string, turns: number): Promise<RunResult> => {
addUserText(messages, WRAP_UP(why));
// 工具定义保留(历史里有 tool_use),用 tool_choice=none 禁止再调
const resp = await ask({ tool_choice: { type: "none" } });
messages.push({ role: "assistant", content: resp.content });
return { answer: textOf(resp), stop, turns };
};
for (let turn = 0; ; turn++) {
if (turn >= maxTurns) return wrapUp("max_iterations", `已达到迭代上限 ${maxTurns} 圈`, turn);
if (now() - start >= softBudgetMs) return wrapUp("soft_budget", "已用完本轮的时间预算", turn);
// ……下面和第 1 章一样:调模型、执行工具、回填结果
def wrap_up(stop: str, why: str, turns: int) -> RunResult:
add_user_text(messages, wrap_up_prompt(why))
# 工具定义保留(历史里有 tool_use),用 tool_choice=none 禁止再调
resp = ask(tool_choice={"type": "none"})
messages.append({"role": "assistant", "content": resp.content})
return RunResult(_text_of(resp), stop, turns)
turn = 0
while True:
if turn >= max_turns:
return wrap_up("max_iterations", f"已达到迭代上限 {max_turns} 圈", turn)
if now() - start >= soft_budget_s:
return wrap_up("soft_budget", "已用完本轮的时间预算", turn)
# ……下面和第 1 章一样:调模型、执行工具、回填结果
你可能会问:为什么不干脆把 tools 去掉?一是历史里已经有 tool_use 和 tool_result 块,保留工具定义,模型看到的上下文和前几圈一致;二是缓存:工具定义排在请求前缀的最前面,删掉它整段缓存都要重建,只改 tool_choice 的话,工具定义和系统提示那段缓存还能命中(消息部分的缓存会失效)。收口提示和最后一组 tool_result 放在同一条 user 消息里(addUserText 负责合并),不会出现两条连续的 user 消息。
软预算的检查也在循环开头,所以它只拦「再开一圈」:正在执行的工具不受影响,结果照常回填。测试里用注入的 now 模拟时钟,验证了这一点。
第二段是打断:
这就是 Codex 的做法:给没执行的调用补一条合成结果,并标成错误。模型下一圈读到「用户已打断,没有执行」,就知道部署没发生,不会以为部署成功了。
这个实验有两处简化,换成真实实现时要补上:
- 只在两次工具调用之间检查打断。 一个跑了十分钟的工具不会被中途停下。真实实现要把 signal 传进工具,让长任务自己响应取消;硬超时也接到同一个 signal 上(Node 里可以用
AbortSignal.any把用户打断和AbortSignal.timeout合成一个),这样两条取消路径走同一套补齐逻辑。 - 没有流式输出。 打断如果发生在模型正在输出的时候,还要处理「只生成了一半的 assistant 消息」,常见做法是丢掉这半条,或者把已输出的文字留下、去掉不完整的工具调用。
完整代码和测试:
对照你的环境¶
- 你在 Claude Code 里派出 subagent 后,主会话拿到它的 ID,之后用 SendMessage 按 ID 或名字继续同一个 agent,它跑完会主动通知主会话。这就是「追加指令按 ID 寻址」和「结果作为新一轮推回」,只是寻址这一步由你或主 agent 显式完成。
- 你写的 PreToolUse hook,就是「用代码闸门代替提示词禁令」:规则写在记忆里只能提高遵守概率,hook 拦下来才是硬约束(Harness 那一章的主题)。
- 如果你在前端接过 Agent 的流式接口,「工程化四」那张帧分类表可以直接当对接清单:每类帧问一句「丢了怎么办、重复了怎么办、断线后从哪补」。
回到开头的问题¶
自己造一个 Agent 从哪下手?先做循环和工具,接上 trace 和评测集,确认它能把任务做对;然后按「多实例、长对话、执行代码、断线」这几类风险,逐层补执行模型、压缩、沙箱、交付协议。
前面拆过的每一章,在这张全景图里都有位置:System Prompt 和压缩在上下文层,文件读写和权限在能力层,Plan Mode 和 Harness 在治理线上。这一章补上的,主要是拆单机工具时看不到的那几层:多 Agent 的派发与追加、多实例下的执行模型、沙箱出网和前端交付协议。
回到主线:模型只会根据上下文预测下一个 token。这一章的每个模块,都是在这个前提下让它看到对的上下文、只能做安全的事、把结果可靠地送到用户面前。
常见坑¶
- 只写了循环,没写好停止:没有迭代上限或收口逻辑,撞上限就报错;被打断后留下半截工具调用,下一轮请求直接被模型接口拒绝。
- 规则只写在提示词里:「不要自己停任务」「必须先读再写」这类规则迟早会被违反,必须有代码闸门。
- 按轮修改工具定义:每轮把动态信息拼进工具描述,缓存命中率归零,成本和延迟翻倍。
- 多实例上线后才想起串行:同一会话的两个请求落到两台机器上并发执行,对话记录交错、同一个请求被执行两次。
- 断线重连整批重放:前端只会追加渲染,重放导致重复内容;应改为游标补差量或快照替换。
练习¶
- 把「动手」里的收口和打断补齐搬进 mini Claude Code 的循环:到达上限时让模型总结并在终端显示结束原因;按 Ctrl+C 时不退出程序,而是补齐半截工具调用、回到输入提示符。
- 给你的 Agent 设计一套「执行中又来一条消息」的策略:哪些轮用 interrupt、哪些轮用 enqueue,并说明为什么不用 steer(或为什么能用)。
- 开放题:对照本章的上线前检查清单,评估你日常用的一个 Agent 产品(或你在做的项目),找出三条最可能出事故的缺口,并写出你会怎么补。
延伸阅读¶
- Anthropic:How we built our multi-agent research system
- Cognition:Don't Build Multi-Agents
- Claude Cookbook:上下文工程(清理工具结果、压缩、记忆)
- LangGraph:double texting(reject / enqueue / interrupt / rollback)
- Temporal:Human-in-the-loop AI agent
- 开源源码:openai/codex · OpenHands/software-agent-sdk · cline/cline · sst/opencode · cloudflare/agents · vercel/resumable-stream · openai/openai-agents-python · langchain-ai/langgraph · e2b-dev/E2B
开源结论基于 2026 年 10 月各仓库默认分支的源码快照,后续版本可能变化。文中的上限数值只是示例量级,请按自己的任务调整。