本篇为《从零构建 7×24 小时 AI Agent》第三章,全书最密集,结合笔者 2024 年早期通用 Agent经验和 2025-2026 Halo AI工作站的实践,全文 3.5万字,共10小节,从历史到现状,从demo 到生产,力求深入浅出,可按需阅读,欢迎勘误
以下全文
人的大脑是一台持续运转的预测机器。它接收感官信号,在几百毫秒内完成模式匹配,发出运动指令,然后立刻处理下一轮输入。这个“感知-判断-行动”的循环每秒重复数十次,你甚至意识不到它在运转。
AI 数字员工的引擎做的是同一件事。它读取环境信息(文件、命令输出、用户指令),交给大模型做推理,把推理结果转化为工具调用(写文件、执行命令、调接口),然后把工具返回的结果再喂回大模型。一个 while(true) 循环,就是它的神经元放电节奏。
但大脑不只是一个循环。一个只会循环的 Agent 能完成演示,处理不了真实任务。这一章从循环出发,分两段走完。上半章回答“它为什么处理不了真实任务”:上下文怎么取舍(3.3)、注意力放在哪(3.4)、工具怎么反馈(3.5)、打转如何自察(3.6)。下半章回答“它为什么不敢交付”:权限(3.7)、子任务(3.8)、成本(3.9)。最后收在一个判断上:这套内核会往哪个方向变(3.10)。
如果时间有限:3.2 是整章的地基,必读;上半章 3.3 到 3.6 环环相扣,顺序读收益最大;下半章三节相对独立,可按需选读;3.10 很短,无论如何建议读完它。
3.1 Agent 如何成为共识
2024 年底之前,Agent 这个词没有统一的定义。同一个词被贴在至少五种不同的东西上:强化学习里那个在环境中行动、以最大化奖励为目标的实体,这个用法比大模型早了几十年;一段带角色设定的提示词,不少面向消费者的 AI 应用把它叫作 Agent;OpenAI 的自定义 GPTs;由多个协作角色组成的系统里的每一个角色;以及 AutoGPT 那种自己拆解任务、自己执行的程序。一个词对应多种所指,意味着行业当时还没有想清楚 Agent 到底是什么。
Agent 简史
思想源头是 2022 年 1 月的 Chain-of-Thought。Google 的一篇论文发现,让模型在给出答案之前先写出中间推理步骤,复杂任务的正确率会明显提升。它解决的是让模型想得更清楚,还不涉及行动。
把行动接进来的是 2022 年 10 月的 ReAct。在它之前已有让模型调用工具的尝试,但 ReAct 第一次把推理和行动交替进行的模式系统化,并成为此后被反复引用的模板。模型的输出不再只是答案,还可以是一条动作指令,动作的执行结果再回到下一轮推理。它论文里的推理过程是这样一串交替的文本:
Thought: 我需要先查这项政策的生效时间Action: search[某某政策 生效时间]Observation: 该政策自 2021 年 6 月起施行Thought: 接下来确认它现在是否仍然有效想法、行动、观察交替推进的这个结构,就是今天所有 Agent 循环的雏形。
2023 到 2024 年,基于这个结构的方案大量涌现,复杂度逐年上升。LangChain 把这些思路封装成框架,很快成为做 AI 应用的默认工具。AutoGPT 让模型自己拆解任务、循环执行,它在 GitHub 上迅速成为当时增长最快的项目之一,把 AI 自主完成任务的画面带给了大众,尽管实际完成率很低。此后两年还有一批同类方案:Reflexion 让 Agent 在失败后用语言反思、把教训写进记忆再重试,AutoGen 用多个 Agent 之间的对话来协作,CrewAI 定义研究员、开发者、审核员这样的角色按预设流程分工,LangGraph 用状态图和条件边把执行过程管理成一张可控的图。
这些名字记不住没关系,后面真正用到的只有 ReAct 那个交替结构。它们的共同点才是重点:都把若干组件拼成一个整体,对外作为一个通用系统。背后是同一个假设,模型本身不够可靠,需要在它外面搭一层结构来约束和纠正它。
这个假设在当时是成立的。2023 到 2024 年上半年的模型确实会在长任务里迷失方向、忘记几步之前做过什么、在任务尚未完成时就宣告结束。面对这样一个组件,在外面加一层控制结构是正确的工程反应。
模型之外的价值
组件越堆越多的同时,另一条线在推进,它对后来的影响更持久。
2023 年 10 月,普林斯顿的一个团队发布 SWE-bench:2294 个任务,全部取自 12 个 Python 开源仓库里真实的 issue 和它们对应的合并请求。模型拿到的是一整个代码库和一段问题描述,判定标准不是答案像不像,而是补丁打上去之后,仓库自己的测试能不能通过。在此之前,衡量代码能力的方式是给一段函数说明、看模型能不能写对,题目是为考试造的;SWE-bench 换成了工程师上班时收到的那种任务。论文里当时表现最好的模型解决了 1.96%。
这个基准后来被反复使用。此后两年,各家模型发布时几乎都要报一次 SWE-bench 的成绩,它也成了后续同类基准的模板。2024 年 8 月,OpenAI 与原作者合作,从中人工校验出 500 个题目清晰、测试可靠的任务,作为 SWE-bench Verified 发布,后来各家报成绩时通常用这个子集。
2024 年 5 月,同一个团队发布 SWE-agent,把成绩推到 12.5%,而半年多前那个数字还是 1.96%。他们把提升归因于一件和模型能力无关的事:模型能用哪些命令、每次执行完看到什么。论文的说法是,Agent 是一类新的最终用户——人写代码有 IDE,模型也该有为它自己设计的一套命令和反馈格式,他们给这一层起名 ACI(Agent-Computer Interface)。同一套系统,换掉调校过的这一层,成绩会明显下降。模型之外那一层值多少分,第一次有了一个别人能复现的答案。
具体做法是一批看起来很小的决定。每一次编辑先过一遍语法检查,不合法就拒绝执行,不让错误留到后面;不让模型直接 cat 整个文件,而是提供一个一次显示约一百行、可以翻页和文件内搜索的查看器;全目录搜索只返回命中的文件名,他们试过附带每一处匹配的上下文,结论是对模型反而更混乱;命令没有输出时,不返回空字符串,而是明确写一句“命令执行成功,没有产生输出”。这些判断和本章 3.5 要讲的几乎重合——那一节是从工具返回值这一侧独立走到同一批结论上的。
同一时期,我们在一家金融机构内部做过一次方向相同的验证:设计一个通用系统,处理简单需求、bug 修复、安全工单、代码扫描这几类研发任务。内核是通用的:一个 Agent 循环,一套读写文件与执行命令的工具,加上返回值反馈和上下文压缩,今天 harness 里的那几样,当时是为了解决各自的具体问题逐个加上去的。其中代码扫描这一项最说明问题——扫描工具报得出缺陷却给不出改法,怎么改取决于这段代码在做什么业务,所以结果在很多项目里长期积压。模型接手之后,报出的问题有一半到六成被自动修复,一百多处经人工 review 进入线上。而我们当时能用的开源模型,比基准上那一批还要弱。
这层东西后来有了统一的叫法:harness。它不是新造的词,软件工程里早有 test harness 的说法。AI Agent 业界其实一直在做这层东西,只是没有专门谈论它;2026 年前后,它随着 Agent 在公众间的流行被集中讨论,才成了显学。而这两篇论文早在 2024 年就把它做了一遍——循环、历史管理、执行前的语法检查都在里面,只是他们把其中最关键的那部分单独命名为 ACI。今天讨论的那一层,源头在这里。
共识的形成
被这些结构补偿的缺陷,正在快速消失。2024 年年中,Claude 3.5 Sonnet 发布,模型对工具调用指令的遵循度从勉强可用变成基本可靠,能够独立地把一个几十步的任务推进到底,而不再需要外部组件替它决定每一步怎么走。
2024 年 12 月,Anthropic 发表《Building Effective Agents》,给出了一个后来被广泛引用的定义:Agent 是由大模型动态掌控自身流程和工具使用的系统,与之相对的 workflow 则由预先写好的代码路径驱动。文章的另一个判断是,最可靠的实现往往不依赖复杂框架,而是采用简单、可组合的模式,从直接调用大模型 API 开始,只在确有必要时才增加复杂度。这篇定义和普林斯顿那条线在同一年汇到一处:Agent 的价值不在外面再包一层框架,而在模型周围那层接口做得有多细。工业界的实现沿用的正是这个方向。
定义发表的同一时期,产品层面的落地也在加速。Cursor 和 Windsurf 把 Agent 模式带进了大众视野——写代码的体验第一次是“描述任务,模型自己搜、自己改、自己跑测试”;2025 年 2 月,Anthropic 发布 Claude Code,把同样的循环做成了命令行形态。2025 年,Agent 在业界遍地开花,单循环加工具调用从各家自己的实现,变成了通用的基本方向。今天的通用 Agent,无论界面形态差异多大,内核都是这一套。
至此,Agent 有了一个清晰的定义:一个大模型在循环中自主决策、调用工具,直到任务完成的系统。
收敛的边界
这个说法容易引出两个疑问:现在不是还有很多别的架构吗?Claude Code、Cursor、Windsurf 等工具,不都能同时开好几个子 Agent 吗?
Claude Code 能够派发子任务,但子 Agent 不是另一套架构,它是同一个循环的又一个实例。主循环里的模型判断某件事应当交给一个独立的子任务,这个判断本身是循环内部的一次工具调用;被派发出去的子 Agent,内部运行的仍是同一个循环。派发子任务没有让架构变复杂,系统依旧是一个循环,外加一个能启动新循环的工具。编排没有消失,而是被并入了循环,成为模型可以调用的一个动作。过去,组件之间谁调用谁由设计者预先接好;现在,这个决定交回给了运行时的模型。
编排框架仍然存在,但要看在什么意义上。在执行路径可以预先确定的场景,例如固定的数据流水线或明确的审批流程,预定义的 workflow 依然是更合适的选择。收敛说的是通用智能体的默认参考架构发生了改变。2024 年初,要搭一个能自主完成任务的系统,可供参考的是一批多组件方案;如今面对同一个问题,得到的答案大多指向一个类似 Claude Code 的单循环。改变的是默认起点,编排并没有失去它的使用场景。

工程在转移
架构收敛成一个简单的循环之后,原先花在架构上的工程并没有消失,而是转移到了另一处。
在多组件架构里,工程的重心是组件之间如何连接、边的条件是什么、状态如何持久化,控制逻辑编码在组件的拓扑结构中。循环架构没有这样的拓扑可调。前面说的那层 harness——循环、工具调用、上下文管理——其中循环本身是平凡的,真正需要下功夫的只剩一处:每一轮往模型输入里放什么。上下文如何压缩、工具返回值如何组织、何时注入一条提醒、历史消息如何裁剪,都是在塑造模型每一轮所看到的输入。

这里要分清一件容易混淆的事。现代 Agent 并非不接受反馈,提醒、压缩、拦截一样在做。区别在于,旧架构里是外部组件替模型做决策:校验者判定对错,状态图决定下一步能走哪条边;而在循环架构里,这些反馈只是模型的输入,决策仍然由模型自己完成。一条“你还没做完”的提示,是递给模型的一张纸条,由它自己决定继不继续,而不是外部直接判定“回到第三步”。harness 塑造的是输入,不替模型做决策。
一些早期思想因此被舍弃,另一些被吸收。被舍弃的是编排中刚性的角色分工。被吸收的是推理、反思与记忆:CoT 的推理成了模型的默认能力,Reflexion 的反思与记忆变成循环内部的机制,记忆将在第七章展开。它们没有作为独立架构留存下来,而是融入了模型与循环本身。
这与前言给出的判断在架构层面相互印证。无论一套系统叫什么名字、叠加了多少层不同命名的工程,最终能改变其行为的,只有注入模型输入的那段文本。架构的收敛使这一点变得无可回避。当外层结构被简化到只剩一个循环,工程便无处可去,只能回到输入本身。
3.2 什么是 Agent
上一节讲了 Agent 如何收敛成“一个自主循环”这个定义。这一节把循环拆开,看它具体如何运转。为了让没有接触过 Agent 的读者也能跟上,先用一个比喻,再落到可以运行的代码。
一个被关在房间里的人
设想一个人被关在一间没有窗户的房间里。门锁着,他与外界唯一的通道是门下的缝隙,可以塞进和递出纸条。
门外有一个助手。助手不替他思考,只做一件事:收到纸条后按上面的要求去执行,再把结果写成新纸条递回去。
有人从门外塞进第一张纸条,写着一项任务:“修一下项目里登录失败的 bug。”
房间里的人看完,写下第一步:“先看报错信息,执行 npm test -- --grep login。”助手运行这条命令,把输出递回去:“FAIL: Expected 200, received 401,位置在 src/auth/login.ts:47。”他据此继续写:“把 src/auth/login.ts 的第 40 到 55 行给我。”助手照做。他看出了问题,写下修改要求:“第 47 行引用的环境变量名有误,把 SECRET_KEY 改成 AUTH_SECRET。”助手改完,他再要求运行一次测试,这次通过。他写下最后一张纸条,说明 bug 已修复及其原因,任务结束。
这个房间就是一个 Agent。房间里的人是大语言模型,门下进出的纸条是上下文,也就是代码里那个不断追加的消息数组。门外的助手是循环代码,它不做任何判断,只负责执行和传递。每一轮递纸条进去、取回复出来、执行、把结果递回去,就是循环的一次迭代。上一节 ReAct 的格式此刻可以对上号:房间里的人在纸条上写下的想法和行动,正是那串交替的文本。

这个比喻里有两个细节,是 Agent 区别于传统程序的关键。
没有人告诉房间里的人具体该怎么做。纸条上没有“先跑测试、再读文件、最后改代码”的预定步骤,他看到报错,自行决定下一步。他可能走弯路,读了无关的文件再退回来,但方向由他在过程中判断。传统自动化脚本要求设计者提前列出每一步,并预想每一种异常;Agent 把这个决策过程交还给了运行时。执行路径不是在设计时画定的,而是在运行时生成的。
还有一件事:他只能看到纸条。不知道外面真实的文件系统,不知道测试实际运行了多久,他的全部信息就是那叠纸条。如果纸条上写着“测试已通过”而实际并未运行,他会据此判断。他的每一步都建立在递进去的文本之上。这解释了前言中的那句话,提示词就是全部:无论在 Agent 外面叠加多少模块和层次,最终能改变它行为的,只有递进门缝的那张纸条上写了什么。
从比喻到代码
把这个比喻翻译成代码,就是一个可以真正运行的最小 Agent。它只依赖一个 LLM 调用库,不需要任何 Agent 框架。下面分五部分把它搭起来,填入 Key 即可运行。
第一部分是模型客户端。这里用 OpenAI 的调用库,因为它的 Chat Completions 格式被大量服务商兼容,改一个 baseURL 就能指向 DeepSeek、通义千问或本地模型。这是教学用的最小示例;真正做项目时,各家都有自己更新的接口形态(OpenAI 现在推荐 Responses API),但循环的结构是一样的。
import OpenAI from'openai'import { readFileSync, readdirSync } from'fs'const client = new OpenAI({ apiKey: process.env.API_KEY, baseURL: 'https://api.openai.com/v1', // 换成 https://api.deepseek.com 即可用 DeepSeek})const MODEL = 'gpt-4o'// 用 DeepSeek 就填 'deepseek-chat'第二部分是系统提示词,也就是房间里那个人的工作说明:他是谁、该怎么做事。提示词是 Agent 最重要的部分,模型的全部行为都由它和后续递进去的输入决定。
const SYSTEM_PROMPT = `你是一个运行在用户电脑上的编程助手。你可以用工具读取文件、列出目录来了解一个项目。信息不足时,先用工具查看,不要凭空猜测;掌握足够信息后,用简洁的中文回答用户。`第三部分是工具,也就是门外的助手能做的事。每个工具用一段 JSON Schema 说明名字、用途和参数,模型据此决定调用哪个、传什么。这里给两个只读工具,列目录和读文件。
const tools = [ {type: 'function',function: { name: 'list_files', description: '列出某个目录下的文件和子目录', parameters: {type: 'object', properties: { dir: { type: 'string' } }, required: ['dir'], }, }, }, {type: 'function',function: { name: 'read_file', description: '读取一个文本文件的完整内容', parameters: {type: 'object', properties: { path: { type: 'string' } }, required: ['path'], }, }, },]第四部分是工具的实际执行,也就是助手动手的地方。模型只能“要求”调用工具,真正去读磁盘的是这段代码。注意最外层的 try:模型第一次就可能传来一个不存在的路径,异常必须转成一段文本交回给它,而不是让整个循环崩掉。
functionexecuteTool(name: string, args: any): string{try {if (name === 'list_files') return readdirSync(args.dir).join('\n')if (name === 'read_file') return readFileSync(args.path, 'utf8')return`未知工具:${name}` } catch (e) { return`执行失败:${(e asError).message}` }}这段示例只做本地演示,路径直接来自模型,没有做任何越界检查。真要放进产品,读写工具必须把路径解析到一个允许的工作目录之内,越界就拒绝——3.7 会讲这类检查该放在循环的哪个位置。
最后是循环,把前面几部分串起来。
const messages: any[] = [ { role: 'system', content: SYSTEM_PROMPT }, { role: 'user', content: '这个项目是做什么的?先看看当前目录有哪些文件。' },]while (true) {const res = await client.chat.completions.create({ model: MODEL, messages, tools })const reply = res.choices[0].message messages.push(reply)if (!reply.tool_calls) {console.log(reply.content)break }for (const call of reply.tool_calls) {const args = JSON.parse(call.function.arguments)constoutput = executeTool(call.function.name, args)messages.push({ role: 'tool', tool_call_id: call.id, content: output }) }}对回比喻,messages 是门下的纸条堆,第一张写着 Agent 的工作说明和用户的任务。create 把纸条递进房间,reply 是里面的人写回来的内容。如果他没有要求调用工具,说明任务已经完成,打印答案退出;否则 executeTool 让助手照做,结果作为一张 role: 'tool' 的新纸条递回去,进入下一轮。
把这五段拼进一个文件,装上 openai 这一个依赖,填入 Key,用 tsx 运行它。你会看到模型先要求 list_files 查看目录,拿到文件名后再要求 read_file 读其中的关键文件,最后用一段话回答“这个项目是做什么的”。整个过程没有人规定它先列目录、再读哪个文件,这些决定都是它自己在每一轮里做出的。这就是那个最小 Agent,五脏俱全:一个模型、一段系统提示词、两个工具、一个循环。

它能完成多少事,取决于给了它什么工具。这里的两个工具是只读的,所以它只能了解项目、回答问题。给它写文件的工具,它就能改代码;接上运行命令和浏览器,它就能测试、部署、操作网页。工具和模型的能力决定上限,循环本身只提供驱动。Claude Code、Cursor 的 Agent、Halo 的对话引擎与数字员工引擎,剥到最内层都是这段代码,形态不同,内核一致。
从最小实现到生产环境
这个最小 Agent 能运行,但让它去做一个真正复杂、需要几十上百轮的任务,会立刻暴露一批问题。仍用纸条来说明。
纸条堆会不断变厚。每一轮都往上叠新纸条,一次读文件就塞进上千行。堆到超过模型一次能读的上限,任务中途就卡死。房间里的人需要一种办法,在纸条太多时决定留什么、压什么、扔什么。
每一轮都在重读整叠纸条。第三十轮递进去的那叠,包含前二十九轮的全部内容,而模型按纸条上的字数计费。同一段历史被反复计费,任务越长越贵。
工具递回来的纸条,决定他下一步怎么走。一个工具返回“失败”,和返回“失败了,建议先用 grep 缩小范围”,会把他引向完全不同的方向。返回文本不只是数据,它是方向盘。
他可能写下危险的纸条,比如“删掉整个数据库”。得在助手动手之前有机会检查、甚至拦下来,又不想去改动循环本身。
有些任务一个房间装不下。当一件事大到一叠纸条容纳不了,或者需要一块干净的桌面来做,就得另开一个房间,让它独立做完再把结论递回来。
这些问题没有一个是 Halo 独有的,任何把 Agent 从能运行做到能交付的人都会遇到。它们还有一个共同点:全都落在同一个循环的圈里——组装输入、递给模型、处理它要执行的动作、把结果喂回去。接下来每一节处理其中一个,而每个解法背后,都是一个比 Agent 这个概念更古老、也会活得更久的设计思想。
3.3 有限窗口下的取舍
回到 3.2 那个循环,有一个细节:每一轮,代码都把整个 messages 数组重新发给模型——从第一条到最新一条,全部重发,而不是只发最新的。
这样做是必须的,因为模型本身没有记忆。两次调用之间,它什么都不会保留,上一轮说过什么、做过什么,它并不知道。它看起来“记得”之前的对话,只是因为我们每一轮都把完整的历史重新发给它看了一遍。换句话说,对话历史就是 Agent 的全部记忆。
问题在于,模型一次能读的文本量有上限。这个上限叫上下文窗口,以 token 计。token 是模型使用的文本分片,一个词可能被切成一片或几片,字符数和 token 数之间没有固定换算比例,不同模型的分词器切法也不同。窗口大小同样因模型而异,主流模型从十几万到百万级不等。而 messages 数组只增不减:读一个大文件,进来几千甚至上万 token;跑一次测试,几百行输出全部追加进去。一个复杂任务跑上几十轮,历史很容易逼近窗口上限。一旦超过,API 直接报错,任务停在半路,之前几十轮的工作就白做了。

所以,长时间运行的 Agent 必须在历史快满的时候腾出空间。怎么腾,行业前后给出过两代答案。
早期的做法:只保留最近的对话
2023 到 2024 年,大多数 AI 应用的答案非常简单:历史太长,就只保留最近的 N 条,更早的不再发送。工程上这叫滑动窗口,几行代码就能实现。在当时,这是一个完全合理的方案——那个阶段 AI 的主要形态是聊天,而聊天是发散的,上个月聊的话题和今天的问题通常没什么关系,旧消息不发,对话质量几乎不受影响。今天回头看容易把它当成权宜之计,但放回当时的场景,它把当时该解决的问题解决了。
变化发生在 Agent 出现之后。Agent 的对话围绕一个任务展开,信息的价值分布和闲聊完全不同:整叠历史里最重要的一条,往往是最早的那条——用户最初交代的任务。“修一下登录模块的报错”这句话,是之后每一轮工作的方向。而滑动窗口按时间取舍,最早的最先停发。于是任务进行到第四十轮,模型手里只剩中间的执行细节——一些报错、几个文件片段、几次修改记录——已经没有一条消息告诉它,这一切是为了什么。它会根据手头的细节自己推测一个方向继续做下去。任务不会报错,只是渐渐偏离最初的目标。

前提变了,方案就得换代。滑动窗口留给后来者的经验是:在任务场景下,按新旧取舍行不通,最旧的那条反而最重要。该琢磨的不是“删哪些”,而是“留哪些”。
哪些该留
哪些该留?有两个问题,可以拿来逐条检验每一段历史。第一个问题:这条信息对接下来的工作还有没有影响?第二个问题:它一旦丢了,还能不能从别处找回来?
拿两段历史对比着过一遍。先看用户最初那句“修一下登录模块的报错”。它对后续每一步都有影响,接下来做的每件事都要拿它对照方向;而且它只存在于这段对话里,世界上没有任何文件、任何工具能把它重新生成出来,丢了就是真丢了。两个问题的答案指向同一个结论:必须留。
再看四十轮之前读过的一个文件,全文两千行,占了整叠历史里最大的一块。模型当时读过它、也根据它改完了代码,对接下来的工作,它只需要记得“我读过并改过这个文件”这一件事,原文本身已经不再参与决策;而且就算之后真的需要原文,文件还躺在磁盘上,再读一遍就是了。两个问题的答案同样指向一处:可以丢弃。
一头是“影响后续、且不可再生”,必须原样保留;另一头是“已经用完、且随时可取”,可以丢弃。绝大多数历史落在两端之间——几十轮的中间过程,有些信息值得留(遇到过什么错误、排除过什么方案),大部分细节不值得留。
先轻后重
按这个标准腾空间,现代 Agent 的做法分轻重两级。
先动的是轻的一级,只处理“可以丢弃”的那一头:把久远轮次里的工具返回结果,原地替换成占位符。那两千行的 login.ts 全文,变成一行“[已省略 login.ts 的内容,共 2000 行,需要时可重新读取]”——占位符不是一句空话,要注明省略了什么、怎么找回。这一级的触发和选择都是机械的:窗口用到七成左右就开始,阈值低于全量压缩,让轻的手段先到;只动工具返回结果,不碰用户消息和模型自己的发言;并且保护最近若干轮——刚读的文件可能马上要用,只清理相隔足够远的旧结果。它敢完全自动地做,是因为判断不需要理解内容:内容类型加轮次距离,两个机械条件就能算出来。
替换之后,对话的骨架一条不动——模型依然看得到“我当时读了这个文件、然后改了它”的完整脉络,只是不再拖着原文。这一级是纯本地字符串替换,压缩动作本身不需要一次模型调用,而且可找回:被省略的内容要么躺在磁盘上,要么重新执行一次命令就能得到。要注意“可找回”不等于“免费找回”——重新读一遍文件要花掉一整轮,就是 3.8 里明确记账的那种重复获取。所以轻级压缩省的是窗口,依据的假设是那段内容之后不会再被用到;判断“相隔足够远”,就是在检验这个假设。工具返回结果通常占历史体积的大头,所以这一级往往能把一次撞线推迟很久。
当对话轮次多到骨架自身也超重——占位符替换完仍然逼近上限——才动用重的一级:让模型把大段历史读一遍,提炼成一份摘要,用摘要替换原文。这要花一次真实的模型调用,而且必然有损。触发时机通常设在窗口用量的九成左右:十八九万 token 的历史,摘要替换后剩一万上下。这一级的判断没有办法机械化——哪段过程值得留进摘要,需要理解内容才能回答。轻重两级的分界,就在“能不能不靠理解”这条线上。

压缩完,任务继续,历史再涨,涨到线再压。整个任务期间,上下文的用量曲线是一条锯齿:涨、压、再涨、再压。

锯齿能一直画下去,不等于质量也能一直保持。摘要之上再摘要,每一次都在已经有损的内容上再压一次,细节会逐层脱水。压缩次数是一个值得盯的指标:同一个任务反复压到第五、第六次,通常说明任务该拆了,而不是该继续压。
重的一级成败几乎全部取决于一件事:那份摘要怎么写。
摘要:从一句“总结一下”开始
最直接的做法,是把历史交给模型,附一句“总结一下前面的对话”。得到的摘要大概是这样:
[摘要] 用户报告了登录模块的问题。经排查发现是环境变量配置有误,已修复并通过测试。期间也处理了一些超时相关的调整,目前正在完善单元测试。通顺,忠实,没有事实错误。一个没读过原文的人看完,确实知道发生了什么。它的问题要放进具体场景才看得出来。
设想任务开头,用户的原话是:“修复登录报错,注意别动现有的 API 接口。”任务推进,报错修好了,压缩发生。模型写摘要时,“修复登录报错”进了摘要——这是任务主线;“别动 API 接口”没进——在模型看来,修复已经完成,这条叮嘱已经用完了。压缩之后,用户又说:“顺便优化一下性能。”模型于是着手重构,顺带改掉了几个 API 的签名。
回放整个过程,模型没有哪一步是失职的:摘要如实概括了工作,重构也确实提升了性能。坏就坏在压缩的那一刻,它替用户的话做了一次取舍——而它判断“还有没有用”的依据是当前相关性,用户的约束却是永久生效的。“别动 API”是长期禁令还是临时提醒,从字面上根本读不出来,需要的是它不具备的、对人类意图的完整理解。既然这个判断做不可靠,办法就是釜底抽薪:用户说过的每一句话,逐字保留,不筛选、不转述。筛选需要判断,而这里恰恰不能信任判断。
摘要是模型对任务的解读,用户的原话是任务的规格。原话还在,解读偏了随时有对照物可以纠正;原话没了,解读就永久替代了规格,偏差从此再也检测不出来。
朴素摘要还有第二个问题,出在它没写的地方。任务后期,模型想确认最初那个报错的具体内容——是 401 还是 500,报在哪一行——摘要里只有一句“环境变量配置有误”。更麻烦的是,它不知道曾经存在过更详细的信息:摘要读起来天衣无缝,它把手头的信息当成全部,带着残缺的记忆继续推进。人忘了事情会意识到自己忘了,会去问;模型意识不到,这是两种遗忘的本质区别。
补救的办法,是在压缩发生之前,先把完整的对话原样存进磁盘,存档永不修改;摘要里再明确标记省略——省掉了什么范围的内容,去哪里找回。模型再遇到“报错原文是什么”,看到“完整记录见 session 存档”,就知道该去读什么。压缩凭什么敢把十八万压到一万?也是因为这份存档:丢掉的东西没有消失,只是从窗口里挪到了磁盘上。上下文只是工作记忆,完整的事实一直在外面。
顺着这两个问题回头看,朴素摘要还有一个整体上的偏差:它写的是“发生过什么”,像给旁观者看的梗概。但摘要真正的读者是下一轮的模型——一个要接着继续工作的接班者。检验标准应该是:一个没读过任何原文的模型,只凭这份摘要,能不能像中断从未发生过一样接着做下去。照这个标准,摘要该交代的是任务此刻的状态:要什么、有什么约束、做了什么、排除了什么、正在做什么、下一步是什么。是交接单,不是梗概。
生产级的压缩提示词
把这些要求全部写成指令,就是生产系统里实际使用的压缩提示词。它的规模比想象中大——下面是一份有删节的版本,真实的还要更长:
对话历史即将达到上下文窗口上限,你需要把它压缩成一份摘要。这份摘要将替代原始历史,成为后续工作的唯一依据。一个没有读过原始对话的模型,必须能够仅凭这份摘要无缝地继续当前任务。正式输出之前,先在 <analysis> 标签内按时间顺序梳理整段对话:每个阶段发生了什么,用户提出过哪些要求和约束,做过哪些改动,出过哪些错、如何解决。梳理完成后再写摘要,确保没有遗漏。摘要必须包含以下部分:1. 任务目标 用户最初的请求。逐字引用原话,禁止改写、概括或解读。2. 用户的全部消息 按时间顺序逐字列出用户说过的每一句话。无论一条消息当前 看起来是否还有用,都必须保留——判断"是否还有用"不是 你的职责。3. 关键技术信息 涉及的文件、函数、命令、报错。报错信息保留原文; 文件改动注明位置和内容。4. 遇到的问题与解决 遇到的错误、原因、修复方式;已排除的方案及排除理由。5. 当前状态与下一步 正在进行的工作,接下来要做的事。附上最近几轮对话的 逐字原文,不要对后续方向做任何推断。6. 省略说明 列出摘要中省略的内容(文件全文、命令完整输出等), 以及每一项的找回方式。输出格式:## 任务目标"(用户原话)"## 用户的全部消息1. "……"2. "……"(其余各节同理)其中几处安排是有来历的。第 2 条那句“判断‘是否还有用’不是你的职责”,针对的就是“别动 API 接口”那类事故——不解释道理,直接把筛选权从模型手里收走。第 5 条要求附最近对话的逐字原文,因为摘要天然偏向过去,而接班者最需要知道的是往哪走,这一段交接不允许经过转述。开头的 <analysis> 环节让模型先梳理再落笔——直接写摘要容易只抓大放小,先过一遍时间线,遗漏会少得多。这份提示词的长度本身也说明问题:把一次交接做可靠,需要交代的事情就是这么多。
摘要如何回到历史里
摘要生成之后,重组的历史长这样:
压缩后(约 1 万 token) [system] 你是编程助手…(原样保留) [user] 以下是之前对话的摘要,原始对话已归档:## 任务目标"修复登录模块的报错,注意别动现有的 API 接口"## 用户的全部消息 1. "修复登录模块的报错,注意别动现有的 API 接口" 2. "顺便把这个函数的超时改成 30 秒"## 关键技术信息 - login.ts:47 环境变量名写错,SECRET_KEY 改为 AUTH_SECRET - 原始报错:FAIL … Expected 200, received 401(login.ts:47)## 遇到的问题与解决 - 首次修改后测试仍失败:.env.test 未同步更新,已修复 - 曾怀疑 token 过期逻辑,经排查排除## 当前状态与下一步 测试已全部通过,正在补登录失败场景的单元测试。 最近对话原文: [assistant] 现在补登录失败场景的单元测试## 省略说明 - login.ts 全文已省略,需要时重新读取该文件 - 各轮测试完整输出已省略,完整记录见 session 存档 [assistant] 现在补登录失败场景的单元测试(原文保留) [tool] (测试运行输出,原文保留)摘要以一条用户消息的身份进入历史,系统提示词原样保留,最近几轮原文跟在后面。模型下一轮看到的,就是“一段交接 + 眼前正在做的事”。
保留“最近几轮”时,切口的位置不能随意选:API 要求每一次工具调用和它的返回结果必须成对出现,切口落在一对中间——保留了 [tool] 却切掉了发起它的那条 [assistant]——请求会被直接拒绝。所以保留边界必须对齐到完整的对话轮,宁可多留一轮,不能切断一对。
另一件事关系到成本。压缩后的历史要作为新的对话历史保存下来,下一轮在它的基础上继续追加。有一种错误做法是把压缩当成发请求前的临时处理:压一份、发出去、用完就丢,原始历史原样保留——这样下一轮又面对同样超长的历史,又压一次,同样的内容反复压缩,每次都花一遍模型调用的钱,什么都没省下来。系统里应当有两份记录,各司其职:磁盘上的存档负责可追溯,从不修改;工作中的上下文负责装得下任务,反复瘦身。压缩是后者的状态变更——压完,新状态就是起点。
压缩也不必等到自动触发。成熟的实现都留了手动入口,还可以附带侧重:“压缩历史,重点保留数据库设计的讨论。”压缩策略本身,也是一段可以被自然语言调节的提示词。
兜底的工程
围绕这套核心,还有三道保护性的防线,按时间先后排开,一道比一道贵。第一道在事前:发请求之前,先在本地估算当前历史大约多少 token,接近阈值就提前压缩,不等 API 拒收。第二道是校准:本地估算不会太准,用每次 API 返回的精确 token 数修正它,让下一次估得更准。第三道兜底:万一还是漏了、当轮撞上上限,就现场紧急压缩再重试,连续几次失败就停下来报错。第三道每触发一次都要赔上一次失败的调用,所以它如果被频繁触发,这件事本身就是警报——说明前两道失守了。极端情况下,历史可能大到连压缩请求本身都发不出去。常见的处理是分段:切成几块分别摘要,再合并;也有实现更直接,舍弃装不下的最旧部分,只对装得下的做摘要。
有一个常见的实现错误:触发阈值要写成窗口大小的百分比,不要写死绝对数字。不同模型的窗口差好几倍,按二十万窗口定的数字,放到三万二窗口的模型上永远来不及触发。
最后两个参数没有标准答案,看场景权衡。一是用到多满才压:九成才压能多保留原文,但逼近上限时风险大;一半就压更从容,但可能把还有用的内容提前压掉。二是压缩时保留最近多少轮不动。任务越长、对成本越敏感,就越往激进的方向调。读任何一个 Agent 的上下文实现,都可以反过来问:这两个数它是怎么定的,为什么。
内存换页
“容量有限、按价值决定去留”,计算机科学处理这个问题已经半个世纪。操作系统的内存管理是最典型的一例:物理内存不够时,把暂时用不到的内存页换出到磁盘,需要时再换回来。被换出的内容没有消失,只是从快的地方挪到了慢的地方——和压缩把丢掉的内容留在存档里,是同一个安排。摘要相当于留在内存里的部分,磁盘上的完整存档相当于被换出的页,摘要里的省略说明,相当于记着“东西被换去了哪里”的页表。
唯一的新东西是判断标准。操作系统判断一页内存该不该换出,看“多久没被访问过”,一个计数器就能算;上下文压缩要判断“这段话对接下来的任务还有没有用”,这需要理解内容才能回答。半个世纪的老问题,只是这一次,回答它需要的不再是计数,而是理解。
3.4 装得下,不等于看得见
压缩解决的是“放不下”。但长任务还有一种走偏,和放不放得下无关:所有信息都在窗口里,一条没丢,任务照样偏了。
场景是这样的。任务进行到第八十轮,3.3 的压缩一直正常工作,摘要的第一行躺着用户的任务原文,一个字没少。最近二十轮的内容全是眼前的细节:一个测试报错、几次搜索的结果、某个配置文件的片段。这时模型注意到配置里有一处不规范的写法,和当前任务无关,但它顺手开始修,一修就是十几轮。目标没有丢——那行字明明就在上下文里——它只是很久没有起作用了。
凭直觉,这不该发生:信息在窗口里,模型每一轮都会完整读到它,怎么会不起作用?这个直觉在 2023 年被一项实验正面检验过。斯坦福大学的研究团队发表了一篇后来被广泛引用的论文《Lost in the Middle》,实验设计很直接:让模型在一段长上下文中回答问题,把答案所在的段落分别放在不同位置——开头、中间、结尾——其余内容不变,然后测准确率。结果画出一条 U 形曲线:答案放在开头和结尾,模型表现不错;放在中间,准确率明显下滑。而且上下文整体越长,整条曲线越低。

这条曲线说明,“读到”和“用上”是两回事。装得下是容量问题,看得见是注意力问题。窗口保证每个字都被读到,不保证每个字都参与决策;参与的程度和位置强相关,离末尾越近,影响力越大。这一点模型和人没有本质区别:两百页的文档你也“读过”,但真正影响你此刻决定的,是刚才有人指给你看的那一页。
放回 Agent 的场景,问题就清楚了。对话历史一直在生长,等于把旧内容持续往“远处”推:任务目标固定在开头,中途用户的纠正、几十轮前确认过的结论沉在中段——恰好是 U 形曲线的洼地——而末尾这个影响力最大的位置,永远被最新的执行细节占着。跑偏不是因为信息丢了,是因为要紧的信息都待在不起作用的位置上。
顺着这个诊断,解法的方向也就定了:光靠 3.3 的减法不够,还需要一个加法——把要紧的信息搬回末尾。
复诵
直觉的做法是把所有重要内容每轮都在末尾贴一遍。这个做法有自己的问题:提醒的力量来自稀缺,每轮贴十条,每条都会沦为背景噪音,模型对“每轮都出现的固定文本”的敏感度会快速下降;而且末尾的内容每轮重发,token 按字数计费。贴什么、什么时候贴,需要筛选。
成熟的手法是:不贴全部,只反复重写“任务现在的状态”。这里借用 recitation(复诵)来称呼它——这个词在不同语境下指的东西不完全一样,本章只用它指“把当前状态重新写到上下文末尾”这一个动作。用过 Claude Code 的读者大概见过它的一个行为——频繁地写待办清单,每完成一步就更新一次。多数人把这当成给用户看的进度展示。这个清单的主要读者其实是模型自己:清单每更新一次,新状态就随着工具结果回到上下文的末尾一次,“我在做什么、做到哪了、还剩什么”就回到注意力最强的位置一次。
注入由条件触发,并非每轮都发生:清单变更了,新状态才回到上下文;清单长时间没动,注入一条提醒;文件被外部改动,注入一条通知。落在消息流里,它长这样:
…(第七十八轮) [assistant] 修好了配置加载顺序,现在跑一遍集成测试 [tool] PASS 12 项 / FAIL 1 项 … [注入·因清单变更] 当前任务状态: 1. [完成] 定位登录报错原因 2. [完成] 修复环境变量加载顺序 3. [进行中] 集成测试全部通过 4. [未开始] 补登录失败场景的单元测试 原始任务:"修复登录模块的报错,注意别动现有的 API 接口" [assistant] (第七十九轮的思考,从这里继续)
图 3-10 复诵把任务状态搬回注意力的落点,而不是指望模型回头看。注入的内容分两类,成本差别很大。一类是事实:任务原文、待办清单、这轮改过哪些文件。这些信息系统手里本来就有,不需要模型生成,回注一次只增加少量输入 token,不花额外的模型调用。另一类是判断:“进展是否偏离方向”“哪些方案该放弃”——这类内容只有模型自己能生成,每写一次都要花一次调用。顺序应当是事实优先:大多数跑偏,一份适时出现的任务原文和待办清单就能拦住——一个经常看得见任务原文的模型,很难把任务忘掉。花钱的判断留给事实拦不住的情况,3.6 会讲到它。
条件触发本身也是设计的一部分:变化才回注,久未动才提示,保持稀缺,提醒才有分量。唯一的例外是任务原文,它最短、最重要,值得在每次注入中都带上。3.3 说过用户的原话是任务的规格,规格不许转述;这里是同一条原则在注意力上的延续:规格不但要逐字保存,还要经常回到看得见的地方。
工作记忆
还有一个问题没有交代:那份待办清单存在哪里?
它不在 messages 数组里。以 Claude Code 为例,待办清单持久化在会话目录下的一个独立文件中,模型通过工具更新它,系统在触发条件满足时把当前状态写进上下文。这个安排带来一个关键性质:压缩碰不到它。对话历史被截断、摘要、替换,清单文件不受影响,下一次注入的仍是完整的当前状态。对话历史是流水,自动堆积;这份清单是状态,独立维护,随任务生灭。一种记忆记录“发生过什么”,另一种维护“现在是什么”。认知科学里后者有现成的名字:工作记忆。
到这里,Agent 的记忆可以分出三层:对话历史,自动记录,会被压缩,是流水账;工作记忆,就是这份随任务生灭的状态,从不压缩;长期记忆,跨任务、跨会话存在,是第七章的主题。三层各有各的生命周期,也各有各的失效方式。这一章前后讲到的不少毛病,追到根上是层与层的混淆:压缩误伤了目标,是把该进工作记忆的内容留在了流水账里;任务跑偏,是流水账淹没了工作记忆。
图 3-11 三层记忆:流水账、任务状态、长期沉淀,生命周期各不相同。三层里,这一节立起来的是中间那层。对话历史的管理 3.3 已经讲过,长期记忆要等到第七章——在那之前,工作记忆加上按条件回注,已经足够让一个 Agent 在几百轮里记得自己是谁、在做什么。
3.5 工具返回值是方向盘
前两节处理的都是输入的组织:3.3 决定历史里留什么,3.4 决定什么该回到末尾。这一节看输入里的一类具体内容——工具执行完,写回上下文的那段文本。
设计一个工具,人们习惯问“它能做什么”。对 Agent 来说,同样重要的问题是:它做完之后,告诉了模型什么。传统软件里,返回值只给代码看,代码不因措辞改变行为——errno 是 2 还是 13,只影响走进哪个分支,把报错信息写得再详细,程序的执行路径也不会多一条。模型不同,它读的是自然语言,返回文本里的每一句话都在参与它对下一步的判断。于是能力和反馈成了两个独立的设计维度:多数工具的能力经过精心设计,返回文本却是顺手写的。
报错要给方向
同样是发现一处语法错误,早年的编译器只输出一行 syntax error;后来的会告诉你错在哪一行、期望什么、实际得到什么;今天的 Rust 编译器直接指明出错的位置,附上修改建议,很多时候给出的补丁可以原样采用。查错的能力从头到尾没有本质变化——错误还是那个错误——变的只是报错的措辞,而三代工具之下,开发者排错的效率差了几倍。报错的义务不是描述发生了什么,而是让读它的一方知道接下来做什么。给模型写报错,道理相同——只是这一次,读报错的不再是人。
同一个编辑操作失败了,两种报错:
第一种: Error: edit failed.第二种: Error: 找不到唯一的替换目标。该字符串在文件中出现了 3 次, 无法确定要修改哪一处。请提供包含更多上下文的更长字符串使其 唯一,或使用 replace_all 一次替换全部。读到第一种,模型只能猜:参数写错了?文件不存在?没有权限?它会换一种写法再试,不行再换——每次试探消耗一轮,而失败的原因可能根本不在它猜的方向上。读到第二种,下一步只有一个动作,而且是对的。
图 3-12 报错的措辞决定失败之后要走多少弯路。这不是构造出来的理想案例。Claude Code 的编辑工具在替换目标不唯一时,返回的就是第二种形态的文本;文件在读取之后被外部修改过,它会报“文件已被修改,请重新读取后再编辑”——连“为什么失败”和“下一步做什么”一起给。报错是模型在最不确定的时刻收到的唯一信息,这一刻的措辞值得逐条打磨:把工具的每条失败路径过一遍,每条问一句——模型读到这句话,知道接下来做什么吗?
截断要给回路
工具结果太大就必须截断,一次搜索可能命中几千行,全部写进上下文,3.3 省出来的空间立刻被填满。要比较的是两种截法:
第一种: (前 200 行结果) [输出过长,已截断]第二种: 共找到 1841 处匹配,以下显示第 1 至 250 处。 查看后续结果请使用 offset 参数(如 offset=250), 或增加过滤条件缩小范围。第一种把模型蒙在鼓里:它不知道被截掉的是什么,不知道多不多、要不要紧,更没有办法取回来,被截掉的内容成了无法评估的损失。第二种多写了两句——总量多少、去哪里取——截断的性质就变了:内容不在上下文里,但在模型伸手可及的地方,需要时它自己会去拿。
这个手法在 3.3 已经出现过一次:摘要里的省略说明,标记出压掉了什么、去哪里找回。压缩之于对话历史,截断之于工具结果,是同一条原则在两个位置的应用——有损操作要留下痕迹,并附上恢复的路径。
工具的粒度
前两条讲失败和超长怎么处理,第三个问题在工具的边界上:一个工具应该管多大一件事?
Claude Code 的早期版本里有一个批量编辑工具:一次调用,对同一个文件提交多处修改。动机很合理——修一个 bug 常常要改三五处,一处一轮太浪费。后来的版本把这个工具移除了,如今的工具清单里只有单处编辑。官方没有解释原因,但从失败形态可以算出这笔账:批量提交的多处修改,只要有一处匹配不上,状态就变得不确定——前面几处应用了没有?文件此刻到底长什么样?模型必须重新读一遍整个文件才能弄清现场,再小心地从头来过。批量省下的轮次,抵不上一次失败赔出去的。
但问题不出在“批量”本身,出在失败之后状态不确定。如果这个工具做成原子的——先检查所有修改是否成立,全部通过才提交,任何一处失败则一个都不改,并且返回每一处的明确结果——模型在失败后仍然知道文件长什么样,这笔账就可能反过来。批量工具回归不会推翻这里的分析,只是用另一种方式满足了同一条要求:写操作失败之后,状态必须是确定的,模型必须能从返回文本里知道现场变成了什么样。单处编辑是满足这条要求的一种办法,原子批量是另一种。选哪种,取决于实现能不能把失败后的不确定性消掉。
读的方向恰好相反,粒度可以粗:单次读取返回更多的信息,几乎没有代价。Claude Code 的读取工具返回的每一行都带着行号——这不是显示习惯,行号直接服务于下一步:“在第 47 行修改”“按行号区间重读”;搜索工具附带命中行前后的几行上下文,省掉一次专门的读取。读取时返回更多信息,最坏的结果不过是浪费一点空间。当然这个“几乎”有一个前提:返回的内容不能多到淹没上下文。一次把整个代码库读进来,虽然没有改坏任何东西,模型却要在几万行里找那几行真正相关的——3.3 讲的容量问题和 3.4 讲的注意力问题会同时找上门。读取返回的信息可以更多,但要以模型还能有效处理为限。
所以“读可以粗,写必须细”是一个在常见条件下成立的经验判断,不是永恒的原则。它背后更耐久的判断标准只有一条:这次操作之后,模型能不能只凭返回的文本,就知道现场变成了什么样。读和写的差别不在安不安全,而在模型的认识会不会和实际状态错位。读不改外部状态,错了重读一次就对齐;写会改外部状态,写完之后,模型想知道现场变成了什么样,只剩一个依据——调用这个写工具之后,它返回的那段结果文本。
人改坏了文件,抬眼就能看到现状;模型没有这双眼睛:它上下文里那份文件还是改之前的旧版本,想知道现在长什么样,要么读这段返回文本,要么再花一轮去重读。一旦部分成功、返回又说不清,模型脑子里的文件和磁盘上的文件就错位了,而它不自知,后面每一步都踩在这份错位上。粒度该粗还是该细,就看这个操作多容易让模型错位、错位之后要花多少轮才能对齐。

粒度还有一个独立的维度,在接口本身:参数和选项的数量。给一个工具塞进十几个可选参数,让相邻的几个工具功能互相重叠,模型每次调用都要先做一轮选择——用哪个工具、传哪些参数、参数之间怎么组合。每一次选择都是一次出错的机会,选择的成本最终摊进轮次和错误率。方向应当反过来:一个工具封装一件独立、完整的事。完整,是让模型一次调用办成一件事,不必连调三次拼出一个操作;独立,是让工具之间边界清晰,模型不必在几个相似的选项里犹豫。工具数量少,不等于接口简单。
这和数据库的表设计是同一门手艺。表是画给人看的业务结构:一张表对应一个清晰的实体,人扫一眼就能建立理解;实体切错了、字段混装了,用的人要在每次查询里付理解成本。工具是画给模型看的系统结构,封装不当,模型在每次调用里付同样的成本。这条标准不只约束 Agent 的内置工具——通过 MCP 给模型接入外部工具的人,等于在给模型设计一套它用来理解和操作业务系统的语言。把业务接口原样铺开成几十个参数繁多的端点,模型的表现会直接反映封装的质量。
每一轮的价值
这些做法最终都在优化同一个量。长任务里最稀缺的资源是轮次:模型每决策一次,就要重新处理一遍完整的上下文,时间和费用都在按轮累积。每一轮的价值,取决于模型能从返回文本里读到多少可用的信息。报错给方向,模型就少花几轮试探;截断给回路,就少几轮迷路;粒度得当,省下的是拼装和犹豫。同一个模型,配上两套设计质量不同的工具,表现出来的能力可以差一截:循环转多快由模型决定,每一圈转得值不值,写在工具的设计里。
工具返回值也不是孤立的一种手段。3.4 的回注用系统已知的事实引导模型,这一节的返回值用执行的结果引导模型——来源不同,落点完全相同:都是写进输入的一段文本。这类手段不止这两种:3.6 的观察判断、3.8 的子任务结论同样如此,本章结尾会把它们放在一起,说明它们是同一件事。
3.6 循环停滞与提前退出
3.2 的 Agent 循环用一条很简单的规则决定是否继续:模型返回工具调用,就执行工具并进入下一轮;模型没有调用工具,就把这轮回复当成最终答案,退出循环。多数时候,这条规则工作得很好。它的边界也很清楚:模型可能一直调用工具,却没有取得进展;也可能任务尚未完成,就不再调用工具。前者是循环停滞,后者是提前退出。
循环停滞
循环停滞最常见的表现,是模型连续多轮调用同一个工具,参数相同或相近,却始终没有得到新的结果:
第 12 轮 read("src/auth.py")第 13 轮 edit("src/auth.py", …) → 替换目标不唯一第 14 轮 read("src/auth.py")第 15 轮 edit("src/auth.py", …) → 替换目标不唯一第 16 轮 read("src/auth.py")这种情况在早期的开源模型上尤其严重。这更多是当时模型训练不足的问题,而不是 Agent 循环的根本缺陷——模型能力提高之后,这种情况明显减少,新的模型已经很少再连续几十轮重复同一个动作。
很少,不等于不会发生。能力较弱的模型仍然容易陷入循环;即使换成新的模型,偶发的错误推理、含糊的工具反馈,也可能让它在几轮之间来回往复。好在这种异常会在执行记录中留下明显的形状:同一个工具带着相近的参数反复出现,同一种错误连续发生,几轮工具调用都没有带回新的信息。
这些信号不需要模型来判断。Agent 可以查看最近几轮的工具调用,统计工具名、参数摘要和错误类型;读取文件时记录路径和内容版本,搜索时记录查询条件和命中集合。相同的调用不断出现,结果也没有变化,停滞的可能性就越来越高。这些都是普通代码可以完成的特征提取,不需要额外发起一次模型调用。
不过,特征必须选对。我们最初考虑过“连续多轮没有修改文件”:任务一直没有产生文件变更,看起来像是没有进展。但探索类任务本来就可能只读不写。连续读取十个不同的文件,逐步理清一个模块的调用关系,是正常的推进;反复读取同一个文件,得到相同内容,才值得警惕。真正要观察的不是有没有产出,而是有没有获得新信息。
检测到停滞之后,系统还要把这个结果告诉模型。它不能只写进监控日志,因为模型看不到日志。合适的位置是最近一次工具调用的返回结果:工具执行结束、结果写入消息历史之前,检测器在末尾附加一段提示。
[tool] src/auth.py 已读取,共 218 行。[停滞检测]最近 5 轮中已读取该文件 3 次;同一编辑操作已因"替换目标不唯一"失败 2 次。请重新判断失败原因,不要重复上一轮操作。下一轮请求组装时,这段提示紧跟着最新的工具结果,处在上下文最靠近生成位置的地方。模型先看到文件内容,接着看到系统从最近五轮记录中提取出的重复模式,再决定下一步做什么。原本分散在多轮历史里的证据,就这样被汇总成一条可以直接用于决策的信息。
这和 3.4 的复诵采用了同一种办法:系统掌握事实,把事实放回模型最容易利用的位置。区别只在触发条件。待办清单变化时回注任务状态;工具调用出现重复时,回注停滞检测的结果。检测和注入不增加一次模型调用,只增加少量输入文本,通常足以让模型换一条路径。
提前退出
另一种问题发生在相反的方向。任务还没有完成,模型却返回了一段不含工具调用的文本:
任务:排查登录接口偶发 500,并定位具体原因模型返回:"目前已确认异常发生在 token 校验环节。接下来我调查 auth 模块的缓存逻辑。"模型没有说任务完成了。它甚至明确写出了下一步,却没有真正调用读取或搜索工具。新的模型仍然偶尔会出现这种情况。
从底层看,这次返回没有任何异常。模型正常结束了本轮生成,流式响应也正常收到结束事件。以 Anthropic API 为例,流中会出现 message_stop,最终消息的 stop_reason 可能是 end_turn;如果模型请求调用工具,stop_reason 通常是 tool_use。这些字段描述的是本轮生成如何结束,不负责判断用户交代的任务是否完成。
Agent 循环看到的则更简单:这轮回复里有没有工具调用。3.2 的最小循环,退出判断大致就是下面几行:
const response = await callModel(messages);const calls = getToolCalls(response);if (calls.length === 0) return response.text;messages.push(await execute(calls));这几行代码判断的是“这一轮还有没有工具可以执行”,不是“用户交代的任务是否已经完成”。前者只要检查响应结构,后者却需要理解任务和当前进展。
对 Agent 不熟悉的读者,可能会以为框架里还有一层专门判断任务完成的机制:模型执行完工作,框架理解了它的回答,确认结果符合用户要求,然后结束运行。最小 Agent 里并没有这层机制。模型结束本轮生成后,框架查看返回结果;如果其中包含工具调用,就执行工具,再把结果送入下一轮;如果不包含工具调用,循环便没有新的动作可以执行,于是把这段文本作为最终答案返回。
因此,一次 Agent 运行包含三种不同的“结束”:
模型结束本轮生成 ↓本轮没有工具调用 ↓Agent 退出循环前两步都是程序可以直接识别的信号,最后一步是框架依据这些信号作出的决定。至于用户要求的工作是否真的完成,并不在这条判断链里。calls.length === 0 只能证明模型这一轮没有调用工具,不能证明任务在语义上已经结束。
这就解释了交互式 Agent 中一个常见的现象:模型写下“接下来我调查 auth 模块的缓存逻辑”,随后却不再继续。它在应该调用工具的时候只生成了一段普通文本,并没有先判断任务已经完成再退出。模型正常结束了这一轮,框架也正常执行了退出逻辑;局部看,两边都没有报错,用户的任务却停在了中途。
这种现象很多时候来自底层模型偶发的不稳定。模型的工具使用能力提高之后,出现频率会下降,但 Agent 框架无法仅凭“本轮没有工具调用”彻底消除它。框架若要判断语义上的完成,还必须获得另一种依据:工作记忆里的未完成项、可以由代码验证的验收条件,或者由另一次模型调用作出的完成度判断。
3.4 建立的工作记忆,正好补上了其中一部分。假设待办清单里保存着:
当前任务状态:[完成] 确认异常位于 token 校验环节[进行中] 调查 auth 模块的缓存逻辑[待处理] 复现问题并验证修复模型没有返回工具调用时,循环先不急着退出,而是检查工作记忆。只要还有“进行中”或“待处理”的项目,任务就没有结束。原来的退出判断可以多加一层:
if (calls.length === 0) {if (!workMemory.isComplete()) { injectPendingItems(messages);continue; }return response.text;}注入的内容不必猜测模型为什么停下来,只陈述系统确实掌握的事实:
[退出检查] 工作记忆中仍有未完成项:- 调查 auth 模块的缓存逻辑- 复现问题并验证修复请继续处理上述任务。下一轮开始时,这段文本位于上下文末尾,模型可以从未完成项继续执行。这里不需要理解上一轮回复的语义,也不需要增加一次模型调用。因为任务进度已经被维护成结构化状态,“是否完成”从一个语义判断变成了几行普通代码就能回答的问题。这也是工作记忆的另一项价值:它不只提醒模型正在做什么,还能为循环提供退出依据。这条重试自身也要有上限:连续注入几次之后进展仍然为零,就该停下来交给用户——否则一份没有及时更新的清单,会让循环带着过时的目标空转下去。
如果任务有明确的验收条件,程序还可以直接检查结果:测试是否通过、目标文件是否生成、接口是否返回预期状态。工作记忆回答“计划中的步骤还有没有完成”,验收条件回答“用户要求的结果有没有出现”。两者都比从一段自然语言里猜测模型是否做完更可靠。
但这些办法都有前提。待办清单必须覆盖任务的完成条件,并且在执行过程中及时更新;验收条件也必须能够用程序表达。如果清单漏掉了关键步骤,系统仍会过早退出;如果已经完成的项目没有被标记,系统又会阻止一次正常结束。工作记忆能让已知状态变得可检查,却不能保证状态本身完整、准确。
没有可靠的工作记忆,也没有可执行的验收条件,或者任务是否完成必须结合语义判断时,才需要观察者。它会另起一次模型调用,读取用户的原始任务、当前进展和最近几轮记录,判断主 Agent 是否真的完成;如果没有,再把判断结果写回上下文,推动主循环继续。
2024 年,我们落地通用 Agent 时设计过这样的观察者。实现并不困难,真正的问题是它什么时候出手。如果每次主 Agent 停下来都调用观察者复核,那么绝大多数调用只会得到一句“任务已经完成”。提前退出本来就是少数情况,却要让每一次正常结束都多付一次模型调用的成本。
因此,今天的 Agent 对这个问题有几种不同取舍。有可靠工作记忆的系统,可以先检查未完成项;任务有明确验收条件时,可以在退出前自动验证;要求长期自主运行、又不能依赖前两者的任务,可以承担观察者的额外成本;交互式 Agent 往往直接停下来,等用户发现任务未完成后输入 continue。最后一种做法不是因为这一层难以实现,而是因为把偶发判断交给用户,通常比每次自动复核更节省 token。
这里没有脱离场景的最优方案。退出检查做得越强,提前停止越少;检查本身消耗的 token 和时间也越多。任务只运行几分钟、用户就在屏幕前时,等待一次 continue 很合理;任务要在无人值守的环境里连续执行数小时,观察者的成本就可能值得。

3.7 不修改循环,改变 Agent 行为
前面几节一直在工具执行之后优化 Agent:返回值写得更清楚,模型就少走弯路;调用开始重复,系统就把停滞信号写回上下文。但有些问题在工具真正执行之前就已经能够确定。这时与其先执行、等结果回来再补救,不如把干预向前移一步。
现实工作中,这样的检查随处可见。假设一位同事准备修改共享目录里的项目计划,但他手上是昨天下载的副本。真正覆盖共享文件之前,文档系统发现服务器上已经有了新版本,要求他重新打开。检查发生在提交之后、写入之前:修改意图已经产生,影响尚未发生,这时阻止最合适。等旧版本覆盖了新文件,再通知他发生冲突,代价已经无法完全收回。
Agent 的工具调用也有这样一个窗口。模型已经返回了工具名和参数,Agent 知道它准备做什么;工具尚未执行,文件、终端和业务系统都没有改变。系统可以在这一刻检查调用历史、当前状态和外部规则,再决定是否执行。
在执行之前反馈
以文件编辑为例。模型准备修改 src/auth.ts,但当前会话没有读取过该文件的最新版本:
[assistant → tool]edit("src/auth.ts", oldText, newText)[执行前检查]最近一次成功读取:无文件当前版本:42模型可能在用户消息或更早的上下文里见过这段代码,但那不代表它掌握磁盘上的当前版本。一条可以由程序检查的规则是:修改已有文件之前,必须读取它的当前版本;读取后文件若被外部修改,必须重新读取。
条件不满足时,Agent 不执行 edit,而是把拒绝原因作为这次工具调用的结果写回上下文:
[tool]本次编辑未执行:尚未读取 src/auth.ts 的当前版本。请先读取该文件,再根据最新内容提交修改。对模型来说,这次调用没有悄无声息地消失。它得到了一条明确的反馈:动作没有执行,缺少什么条件,下一步应该做什么。下一轮,它通常会先读取文件,再根据最新内容重新编辑。
这和 3.5 讨论的返回值设计只差一个位置。3.5 关心工具执行以后怎样反馈;这里关心工具执行以前能否发现问题。两者最终都要回答同一个问题:模型下一轮需要知道什么,才能继续完成任务。
执行前检查也要有边界。程序适合判断的是确定的事实:文件是否读取过、版本是否变化、路径是否越界、某项业务状态是否存在。至于修改方案是否合理、代码设计是否足够好,仍然需要模型理解内容,不能包装成一条普通的程序规则。
当规则挤进循环
只有“编辑前读取当前版本”这一条规则时,最直接的实现是在工具执行前增加一个条件:
if (call.name === "edit" && !hasCurrentVersion(call.path)) { appendToolResult(call, "请先读取文件的当前版本");continue;}这几行代码没有问题。问题出在规则不会只有一条。
Agent 框架可能希望检测重复调用;宿主应用可能要求 Shell 命令只能访问工作目录;接入业务系统的开发者又可能规定退款之前必须读取订单状态。这些规则分属不同层次:
Agent 内核不可能预先枚举所有规则。它不认识每一家公司的订单、审批和发布流程,也不应该因为某个业务增加一项约束,就修改一次核心循环。
如果继续增加 if,工具调度代码会逐渐混入质量检查、安全策略和业务逻辑。新增规则要修改内核,删除规则也要修改内核;一次工具调用究竟经过了哪些检查,则散落在不同的条件分支里。循环原本只负责调用模型、执行工具和追加结果,最后却承担了所有接入系统的业务策略。
这是一个经典的软件设计问题:执行流程相对稳定,介入流程的规则不断变化。解决办法是在稳定的位置留下扩展点,而非继续扩充流程。
可拦截的生命周期
Agent 循环中,适合外部规则介入的位置并不多:
模型提出工具调用 ↓工具执行前 ↓执行成功 / 执行失败 ↓结果写回上下文 ↓循环准备结束内核运行到这些位置时,调用外部注册的代码。这样的扩展点通常称为 Hook,也就是钩子。
它和 Web 中间件或拦截器很接近:一次工具调用沿着固定流程向前传递,经过 Hook 时,可以被检查、修改或拒绝。从更底层的设计看,这是控制反转。业务代码不再进入 Agent 循环寻找插入位置,而是由内核在约定的生命周期节点调用它。内核规定什么时候可以介入,外部代码决定介入时检查什么。
Hook 返回一组固定的处理结果,主循环按结果放行、改写或拒绝调用,无需知道规则检查的具体内容。概念上的代码并不复杂:
const decision = await hooks.beforeTool(call, state);if (decision.blocked) { appendToolResult(call, decision.message);continue;}const result = await executeTool(decision.call);appendToolResult(call, result);今天注册“编辑前读取当前版本”,明天增加“退款前查询订单”,这段循环都不需要改变。稳定的是模型调用和工具执行的流程,变化的是流程外部的规则。
图 3-15 Hook 介入一次工具调用的过程:在执行前检查,在执行后处理,并把结果写回模型上下文。为什么不只写在提示词里
“编辑文件之前先读取”“退款之前先查询订单”,都可以写进系统提示词或工具描述。这样做当然有用:模型看到规则以后,遵循正确调用顺序的概率会提高。
但提示词只能影响模型的选择,不能证明这一次调用确实满足了条件。模型可能遗漏规则,也可能认为上下文里的旧内容已经足够,直接发出 edit。工具越多、任务越长,某一条说明离生成位置越远,这种情况越容易发生。
Hook 处理的是另一层问题。它不问模型是否记得规则,而是检查程序能够验证的事实:当前版本是否读取过,目标路径是否在允许范围内,订单状态是否已经取得。条件不满足,工具就不执行。可以用代码确定的事情,没有必要再调用一次模型判断,也不应完全依赖模型自行遵循。
提示词和 Hook 并不互相替代。工具描述先告诉模型正确的使用方式,减少不合理的调用;Hook 检查仍然出现的违规调用;拒绝以后,Hook 返回的原因又成为模型下一轮看到的提示。程序守住可以验证的边界,自然语言帮助模型在边界内继续完成任务。
把规则留在内核之外
单个工具通常只能校验这次调用的参数,很多规则却依赖调用历史和外部状态。文件编辑需要知道模型读取的是不是当前版本;退款操作需要知道当前订单是否已经查询、审批状态是否有效。这些约束描述的不是一个参数,而是一段执行过程。
Agent 内核不需要认识订单和退款。业务方可以为退款工具注册执行前 Hook,检查业务系统中已有的状态;条件不满足,就拒绝调用,并返回缺少的信息。接入一个业务工具,也不再只是提供名称、参数和说明,还可以提供它的前置条件和执行边界。
如果 Hook 修改了参数或拒绝了调用,最终执行了什么、为什么没有执行,都要写进工具结果。否则就是 3.5 讲过的那种错位:模型对现场的认识离开了真实状态。程序可以在模型之外改变控制流,但改变之后的结果必须重新告诉模型。
能够修改或拒绝调用的 Hook,还需要按照确定的顺序执行。假设一个 Hook 先把相对路径转换为绝对路径,另一个 Hook 再检查路径是否位于工作目录内,安全检查必须看到转换后的最终路径。否则系统检查的是一组参数,工具实际执行的却是另一组参数。
提供 Hook 的意义,不是让 Agent 内核拥有更多规则,而是让未来无法枚举的规则能够进入工具执行流程,同时留在内核之外。
图 3-16 退款规则仍由业务模块维护,只通过 Hook 进入工具执行流程,不进入 Agent 内核。Hook 也不是越多越好。每暴露一个生命周期节点,就等于把一部分内部流程变成对外承诺。扩展点太少,外部规则只能重新进入内核;扩展点太多,循环里的实现细节都会变成难以修改的扩展协议。对于通用 Agent,工具执行前、执行成功后、执行失败后,再加上少量任务级事件,通常已经足够。
3.8 子 Agent 的收益与代价
3.1 说过,子 Agent 没有让系统重新回到多角色编排。对主 Agent 来说,派发子任务仍然是一次工具调用:模型写出任务说明,系统启动另一个独立的 Agent 循环,等它完成之后,再把结果作为工具返回值写回主 Agent 的上下文。
因此,子 Agent 运行的仍是 3.2 的那个循环,只是拥有独立的消息历史和上下文窗口。需要判断的是这项工作是否值得跨过一次上下文边界。能不能再启动一个模型,从来不是问题。
通信没有魔法
两个 Agent 之间最简单的通信,可以用几行代码表示:
asyncfunctionrunSubAgent(prompt: string) {const result = await runAgent([ { role: "user", content: prompt } ])return result.finalText}父 Agent 把任务说明传给子 Agent,子 Agent 把最终结果返回父 Agent。前者成为子 Agent 上下文里的第一条任务消息,后者成为父 Agent 上下文里的一条工具结果。底层模型没有因此多出一种通信能力:它仍然只读取当前请求里的上下文,再生成一段回复。
从主 Agent 的视角看,runSubAgent 和 read、search 没有结构上的区别。它们都出现在工具清单里,也都接收参数、执行外部代码,再返回结果:
主 Agent 的工具清单:- read(path)- search(query)- runSubAgent(prompt)区别只在工具内部:read 打开一个文件,search 执行一次搜索,runSubAgent 启动另一个 Agent 循环。模型选择它以后,主循环仍然按照普通工具调用处理:取出参数、执行工具、接收结果、写回上下文。所谓派发子 Agent,在技术上就是把“启动另一个循环”封装成了一个工具。
多 Agent 系统当然可以做得更复杂。几个 Agent 可以把进展写进同一个 Markdown 文件,也可以使用数据库、消息队列或共享内存;所谓“黑板模式”,也是让多个 Agent 读写同一份公共状态。这些方案解决的是传统软件问题:信息存在哪里、怎样传递、并发读写时如何保持一致。它们会影响系统的可靠性和运行效率,却不会自动提高模型的判断能力。
不管用什么手段,没进上下文的信息,模型一概不知。
因此,存储和通信是多 Agent 系统必须完成的工程,却不是 Agent 设计者最该投入判断力的地方。实现信息传递有大量成熟方案,真正需要结合任务和业务决定的是:每个 Agent 作出下一步判断时,应该看到什么,又不应该被什么干扰。通信机制负责把信息送过边界;上下文设计负责决定什么信息应该跨过边界。多 Agent 的质量,最终仍然落在上下文的质量上。
隔离与交接
为什么还要启动子 Agent?一个常见场景是代码调研。
主 Agent 正在修复登录接口的偶发错误。为了查清认证模块的结构,它可能要搜索几十次,读取三十个文件,排除若干无关分支。主任务最后需要的却只有几项内容:请求从哪里进入,token 在哪里校验,哪些分支可能返回 500,以及每项判断对应的代码位置。
如果全部工作都由主 Agent 完成,几十次搜索和文件内容都会进入主上下文。调查结束了,中间材料仍然留在那里,占用窗口,也分散后续修改时的注意力。交给子 Agent,调查过程留在独立上下文中,父 Agent 最后只接收一份结论。
这就是子 Agent 最直接的收益:隔离。大量只对子任务有用的中间信息不必进入主任务的历史;目标更窄的上下文,也更容易让模型把注意力集中在一件事上。几个彼此独立的调查还可以同时执行,换取并行收益。
隔离不是免费的。子 Agent 启动时并不知道父 Agent 已经经历了什么:用户真正要解决的问题是什么,哪些方向已经排除,哪些文件不能修改,返回结果需要详细到什么程度。父 Agent 必须把这些信息重新写成任务说明。子 Agent 完成后,又要把探索过程整理成一份结果,交还给父 Agent。
可以把这项取舍写成一笔简单的账:
子 Agent 的价值 = 隔离与并行的收益 − 任务交接与结果回收的成本
这个公式不能代入数字计算,它是划分任务时的一种思考方式。复制更多父上下文,子 Agent 得到的背景更完整,但主任务中的噪声、token 和注意力问题也会被一并复制;只传一份简短的任务说明,隔离最彻底,交接质量却完全取决于父 Agent 的表达。
可用的方案通常位于两端之间:不复制整个对话,只传递子任务需要的目标、已知信息、执行边界和返回要求。
交接要重建什么
交接的目标只有一个:重建子 Agent 独立完成任务所需的最小状态。通常要回答四类问题:
目标:要解决什么问题,做到什么程度才算完成; 上下文:已经知道什么,提供了哪些输入,排除了哪些方向; 边界:允许做什么、不能做什么,可以使用哪些工具和资源; 返回要求:交回什么内容,采用什么格式,结论需要哪些依据。
这四类内容不是要求每次都填写的固定表格。不同任务各有侧重:调研任务需要来源或代码依据,修改任务需要当前文件和验收条件,业务操作还需要权限与前置状态。设计交接的目的,是让子 Agent 不必猜测完成任务所需的关键信息。
下面这份任务说明看起来合理,实际很难得到稳定结果:
看看认证模块有什么问题。“问题”指什么?是代码质量、安全风险,还是登录接口的 500?只读调查还是允许修改?父 Agent 需要一份概括,还是完整列出所有发现?子 Agent 只能自己猜。
把任务写成下面这样,边界才开始清楚:
只读调查 src/auth/,不要修改文件。目标是定位登录接口偶发 500 的原因。已确认异常发生在 token 校验阶段。返回请求入口、校验路径、可疑分支及代码依据。列出仍无法确认的问题,不讨论无关重构。在这个案例里,“定位登录接口偶发 500”是目标;“异常发生在 token 校验阶段”是已经掌握的上下文;“只读,不修改文件”划出了执行边界;“附代码依据,列出未确认问题”规定了结果怎样才能被父 Agent 使用。任务说明没有复制完整历史,只保留了子 Agent 继续工作所需的状态。
这和 3.3 的上下文压缩有相似之处。两者都不是复述过去发生了什么,而是从一段历史中提取足以继续工作的状态。区别在于:压缩写给同一个任务的下一轮,交接写给一个从空白上下文开始的新循环。
Agent 框架可以处理子 Agent 怎样启动、怎样并发、结果送到哪里,却不能替父 Agent 写好这份交接。runSubAgent、任务 ID、状态查询和结果回传都容易实现;难的是把父上下文里隐含的目标和限制,转换成子 Agent 可以独立执行的说明。
把判断写进工具
父 Agent 不会凭空写出一份交接。它是在 read、search、runSubAgent 等工具之间作出选择,再根据工具的说明和参数组织任务。因此,前面关于上下文边界的判断,最终要进入 runSubAgent 的工具描述,出现在主模型真正选择工具的位置。
下面是一份控制过长度的工具说明:
runSubAgent:让一个独立 Agent 在新的上下文中完成子任务。适合:- 需要多轮搜索、读取和比较的开放式调查;- 可以独立完成,主任务只需要结论或产物的工作;- 与当前工作互不依赖、适合并行的任务。不适合:- 已知文件或位置,一次读取或搜索即可完成;- 父 Agent 随后还要围绕原始细节继续判断;- 依赖当前对话中大量隐含背景的小任务。派发时,说明目标、必要背景、已排除的方向和返回要求,并明确只读调查还是允许修改。不要把对问题的理解和最终判断一并交给子 Agent。这份说明先建立了一条成本顺序:一个确定的工具调用能够完成,就不要建立新的上下文边界;需要多轮自主探索,而且主任务只需要整理后的结果,派发才可能划算。它还保留了父 Agent 的责任:子 Agent 可以搜索、读取和整理证据,父 Agent 仍要理解返回结果,并据此作出最终判断。
具体接口会因框架而异。下面使用一种通用的概念接口,只保留与本节有关的两个参数:
runSubAgent({ prompt: string, run_in_background: boolean})prompt 是完整的子任务说明;run_in_background 决定父 Agent 是否等待。下面是一次较为完整的前台调用:
runSubAgent({ run_in_background: false, prompt: `只读调查 src/auth/,不要修改文件。目标:定位登录接口偶发 500 的可能原因。已知:异常发生在 token 校验阶段。返回请求入口、校验路径、可疑分支及代码依据;另列仍无法确认的问题,不讨论无关重构。`})这段代码不是某个框架必须采用的标准接口。有的实现只提供一个 prompt,有的还允许选择模型、工具权限、工作目录和隔离方式。字段名不重要,重要的是参数承担的两项职责:把完成子任务所需的信息交代清楚,并说明父任务是否依赖它的结果。
子 Agent 完成后,返回值也不能只是“调查完毕”。父 Agent 需要的是能够支撑下一步判断的结果:
结论:1. 请求入口:src/auth/login.ts / handleLogin()2. 校验路径:handleLogin() → verifyToken() → loadSession()3. 可疑分支:loadSession() 的缓存读取异常未被转换,可能返回 500。证据:src/auth/session.ts 中的缓存读取分支。未确认:缺少生产环境错误栈,尚不能确认是否命中该分支。结论告诉父 Agent 下一步往哪里查,证据让它可以按需重新读取,未确认项防止它把推测当成事实。返回格式不一定要固定成 JSON,但应当让父 Agent 分得清哪些内容可以直接使用、哪些仍需验证。父 Agent 要基于这些材料形成自己的判断,而不是把子 Agent 的结论原样转交给用户。
有些系统会把 prompt 拆成 objective、context、constraints、deliverable 等结构化字段。这样便于检查缺项,却会让工具接口变重;字段之间也可能重叠,并非每个任务都需要填写每一项。单个 prompt 和结构化参数没有普遍的优劣,最终都需要把足够的交接上下文给到子 Agent。
什么时候不该派发
调研经常适合子 Agent,但不是所有调研都适合。区别在于调查产生的细节,父 Agent 接下来还需不需要。
假设用户要求分析 src/auth.ts,随后要和 Agent 逐段讨论如何修改。父 Agent 如果把阅读工作交给子 Agent,拿回来的通常是一份摘要。用户接着问:“第三个分支为什么不能直接删除?”父 Agent 手里没有子 Agent 刚才看到的完整代码,只能重新读取文件。
这次派发发生了两次信息压缩:父 Agent 先把当前任务压缩成子任务说明,子 Agent 又把阅读过程压缩成返回结果。用户追问的细节只要没有穿过第二次边界,就要重新获取。原本希望节省上下文,最后却多出一次派发、一次摘要和一次重复读取。
这种不合时宜的派发,在早期模型上尤其常见。模型看到“调查”“搜索”之类的任务就启动子 Agent,却没有判断调查结果是否马上要参与当前对话。即使到今天,模型仍可能为了一个紧贴当前上下文的问题贸然拆分任务。这也是不少深度用户会在提示词里明确写“不要使用子 Agent”的原因:错误的边界比不拆更昂贵。
图 3-18 当中间材料很多而主任务只需要结论时,隔离是收益;当后续决策仍依赖原始细节时,隔离会变成重复获取信息的成本。这个案例解释了为什么派发原则必须写进 runSubAgent 的工具说明。作出判断的是准备调用工具的父模型,Agent 框架无法用普通代码提前知道用户稍后是否会追问某段原始细节。工具说明负责降低误判的概率;预算、并发、权限和工作区冲突等可以确定的边界,再由程序限制。前者是语义判断,后者是运行约束,两者不能互相替代。
等待,还是并行
子任务启动后,父 Agent 是否等待结果,取决于后续工作是否依赖它。
前台模式最简单。父 Agent 发出子任务后暂停,等子 Agent 完成,把结果作为工具返回值拿回来,再继续下一轮。前面的认证模块调查就采用这种模式,因为修改登录流程之前,父 Agent 需要先得到调查结论:
调查认证模块 ↓ 等待结果根据调查结论修改登录流程后台模式适合真正独立的工作。调用时将 run_in_background 设为 true,工具立即返回一个任务 ID,父 Agent 继续处理其他内容,子 Agent 在后台完成。
如果父 Agent 后面仍要使用结果,可以再提供一个按任务 ID 取回结果的工具:
runSubAgent(..., run_in_background: true) → task_id父 Agent 继续处理其他工作getTaskResult(task_id) → 取得结果这就是传统并发程序中的 fork-join:先让任务并行,真正需要结果时再等待。它能提高并行度,也增加了任务 ID、生命周期和“应该何时取回结果”的决策。系统可以只提供语义最清楚的前台和后台模式,也可以在确有需求时增加显式的结果查询。选择取决于并行收益是否值得增加一套模型必须理解的工具语义。
无论采用哪种模式,子 Agent 的消耗都要归入主任务。后台任务还要处理取消和失败:主任务终止以后,子任务是否继续;子任务失败以后,错误送到哪里。这些是重要的运行边界,但仍然属于传统的并发任务管理,不是多 Agent 智能质量的来源。
谁来划定多层关系
子 Agent 能否继续派发,不需要规定统一答案。简单系统可以限制一层,换取清楚的成本和控制流;需要树形搜索或大规模并行的系统,可以允许继续拆分,但要对整棵任务树统一限制预算、并发和权限,并让取消与错误能够向下传播、结果能够向上追溯。
多层关系也不一定由模型在运行时生成。Halo 的数字人团队采用了另一种做法:用户可以通过拖拽配置一张多层 Agent 关系图,预先决定任务可以沿哪些关系派发、结果向哪里汇总。模型仍然负责具体任务中的分工与调用,但只能在用户设定的关系内行动。与完全在运行时展开任务树相比,这种方式减少了一部分自由度,换来了可见、可检查的协作范围。
因此,层数只是实现参数。更重要的问题是:任务树由谁展开,可以展开到哪里,以及成本、权限和结果能否沿着这棵树正确传递。用户可以预先画出边界,也可以让模型在运行时决定边界;两种设计对自主性和可控性的取舍不同。
模型越来越会派发任务
早期模型并不擅长使用子 Agent。它可能为一个很小的问题贸然派发,也可能把紧接着还要使用的代码细节隔离出去。为了降低这类误判,工具说明往往要写得很具体,有些用户甚至会直接要求“不要使用子 Agent”。
随着模型能力提高,这种情况正在减少。新的模型更能判断任务是否独立、哪些背景需要交接,也更能汇总多个子任务的结果。原来需要人逐项规定的派发经验,正在被训练进模型本身。所谓模型越来越擅长调度其他模型,指的是模型开始接管过去由人完成的任务划分与交接——spawn、共享数据库和任务队列这些管道并没有变。
这并不意味着 Agent 工程可以撤掉所有指导。模型作出的仍然是概率判断,在长任务、陌生业务和并发修改等场景中仍会失误。Agent 开发者要把少量稳定的派发原则写进工具说明和系统提示词,引导父模型关注任务能否独立、哪些背景必须交接、返回结果是否足以支持后续判断;预算、权限和并发等可以确定的边界,则由程序控制。这些机制不替模型决定每一次派发,而是提高判断正确的概率,并控制判断失误时的代价。
接口和通信方式还会继续变化,Agent Team 也会带来更多消息路径。随着模型能力提高,任务划分、上下文交接和结果汇总正在逐渐成为模型的默认能力,需要写进提示词的具体规则会越来越少。但只要父子 Agent 使用独立的上下文,信息就必须经过一次选择和转述:父模型要决定哪些背景随任务一起交给子 Agent,子 Agent 也要决定哪些结论和细节带回主任务。通用训练可以提高这种判断能力,工具说明可以校准它,业务相关的上下文仍然要由系统在运行时提供。
3.9 顺序等于钱
3.2 说过,每一轮都要把完整的消息历史重新发给模型。第三十轮的请求里装着前二十九轮的全部内容,而模型按 token 计费。同一段系统提示词,一个六十轮的任务要被计费六十次。粗算一笔:系统提示词两万 token,六十轮就是一百二十万 token 的输入费,绝大部分花在重新处理已经处理过的内容上。
服务端有一种机制专门解决这件事:前缀缓存。原理是:模型处理输入时,会按顺序把每个 token 的中间计算结果存下来,供后面的 token 查用——可以把它想成每个字给排在它后面的字留了两张便签:一张当索引(Key),一张当内容(Value)。后面的字要判断语义时,先比对索引,比对上了再取内容,不必回头重读原文。这两张便签只由前面的字决定,跟后面还会来什么字无关。所以一段文字算过一次,便签存下来,下一次请求开头一模一样,这些便签直接复用,不再重新计算。服务商文档里把这套中间结果叫 KV 缓存——K 和 V,就是这两张便签。
也因此,能复用的只能是“从开头到某个位置、一字不差”的那一段。中间改一个字,从它开始,后面每个字的便签都得重算——前面给后面留的便签是基于旧内容算的,字变了,便签就不对了。
图 3-19 缓存复用的是前缀;中间一改,后面全部失效。前缀缓存的原理是通用的,但各家服务商的实现方式分两类。一类是自动缓存:服务端自行识别请求之间的公共前缀,开发者不需要做任何标记,OpenAI 和 DeepSeek 属于这一类,通常有最小前缀长度的要求(如 1024 token 起)。另一类是显式断点:开发者在请求里标记“缓存到这里为止”,Anthropic 的 cache_control 属于这一类。两类实现都有过期时间(TTL),缓存不是永久的——Anthropic 默认 5 分钟,超过不续就过期;这对 7×24 小时但调用频率不高的任务是一个真实变量。两类缓存命中后,输入 token 按折扣计费(通常约原价的十分之一),命中不等于免费。
无论哪类实现,管用的规则都一样:前缀稳定就能复用,中间一改就失效。
最直觉的用法,是把缓存当成一个开关:内容照旧按阅读顺序排,当前时间、工作分支、剩余预算这些“现在需要知道的”放最前面,后面再跟身份、规则、工具用法,然后打开缓存。第一轮没问题。第二轮时间变了,从这个时间戳起,后面几万 token 全部作废。缓存不是开关——它要求内容本身按另一种顺序排。
把变的和不变的分开
没有缓存时,纸条怎么排,主要看阅读顺序。有缓存以后,顺序直接等于钱。
内容只分两类:从任务开始到结束都不变的,和会变的。不变的——身份、规则、工具用法——排最前;会变的——最新的对话、任务状态回注——排最后。会变的内容一旦进了前排,它每变一次,身后的一切重新计价。
在没有压缩的理想情况下,对话历史每轮只追加、不修改,断点可以跟着最新一条消息往后挪:上一轮的完整历史,就是下一轮的前缀。像读一本越来越厚的书,书签永远夹在最新一页,前面的不必重读。
但 3.3 讲过,长任务必须压缩。轻级压缩会原地替换历史中段的工具返回结果,两千行的文件全文变成一行占位符;重级压缩更彻底,把大段历史整体换成摘要。替换一旦发生,从替换点开始,后面所有 token 的缓存全部失效——前面给后面留的便签是基于旧内容算的,内容被替换了,便签就不对了。所以压缩和缓存之间存在真实的冲突:压缩省窗口,却击穿缓存;缓存省算力,却要求前缀不变。
实际的解法是把两者的触发时机错开。压缩通常在窗口逼近上限时才发生,两次压缩之间可能隔几十轮;在这几十轮里,历史只追加不修改,前缀缓存正常复用。压缩发生后,断点退回到变更点之前,后续内容按全价重新计算一次,然后新的前缀重新开始积累缓存。代价是压缩那一轮有一次性的缓存击穿成本,但换来了窗口空间以继续运行。
注意力与账单
对照 3.4,先看到一个巧合。任务状态回注要求占据上下文的末尾,让模型在最近的位置看到当前进展;缓存要求会变的内容排在最后,否则每次更新都会击穿前缀。两个不相干的约束指向同一个位置,这个设计两头都满足。
也有真冲突。项目规范这类内容体积大、位置靠前,任务中途需要修改其中一条规则时,直接改原文,整条前缀从头作废。可行的办法是不动前面的大块:把本次新增或修改的规则作为一小段新内容注入尾部,并提醒模型“本次任务新增/调整了规则 X”。稳定大块继续吃缓存,变化的小段放尾部随便改。代价是模型多读一小段,收益是整条前缀不失效。
设计时永远先问注意力——它决定模型做不做对;再问缓存——它决定做对这件事要花多少钱。缓存命中率是账单的读数,不是任务的成绩。两者多数时候一致;一旦冲突,真正要问的是:这笔钱,值不值得拿模型的注意力去换。
3.10 内核会越来越薄
上半章四节,让房间里的人能处理真实任务:纸条太多时留什么,该看见的东西放在哪,工具怎么把结果说清楚,打转的时候怎么让他自己看见。下半章三节,让这个房间敢交付出去:哪些事不许他做,哪些事该另开一间房,每一张纸条要花多少钱。
这些加起来,循环还是 3.2 那个循环:递纸条进去,取回复出来,执行,把结果递回去。每一样工程都作用在同一个地方:这一轮,递什么纸条。
其中有一部分补的是模型此刻的短板——怎么规划、先读哪个文件、用什么策略搜索、什么时候该整理待办。它们之所以要由我们写进纸条,是因为模型自己做得还不够稳。模型每强一代,这里面就有一批可以删掉。3.8 讲过,原来要人逐项写进工具说明的派发经验,正在被训练进模型本身。所以写的时候就该知道,这部分是临时的,它服务的是这一代模型。
但删不是终点。模型强一代,上一代堆的工程有一批变成负担;在新一代上重新做一遍,又能往前推一截。两头交替向前,这一层始终存在,只是每一代的内容都要重写。
有四样东西,房间里的人再强也给不了自己。他碰不到文件系统和网络,门外必须有那个助手——他没有身体。这一轮结束、下一轮开始,屋里不留下任何东西,全靠我们把整叠纸条重发一遍,而纸条只能重发到窗口装不下为止,于是有了压缩,有了那份不参与压缩的工作记忆——他的记忆装不下。
写在纸条上的禁令只是建议,真要拦住一件事,得在他动手之前从门外拦;这一样不是他做不到,是这件事不该由被约束的一方自己掌管——他给不了自己边界。他只有一份注意力,另开一间房,只能由房间外面来开——他没有分身。
还有第五样。前面说模型会一代比一代强,长的是能力:更会规划,更会搜索,更少走回头路。能力总要用在具体的事上,而具体的事发生在你这边:订单卡在哪个环节、这个文件里写了什么、命令为什么报错,他都不知道,只能从门下那道缝递进去。换一个更强的人进那个房间,他带来的是更好的判断,不是这个项目的信息。
所以这一层不会消失,只是内容在换。从前花在纸条上的力气,多半用来教他怎么做事;以后这部分越来越不必写,力气会挪到另一头:把项目里正在发生的事,准确地递进去。3.1 说过,架构收敛之后,工程最终都落在输入上。模型再强一代,这句话依然成立——变的只是输入里该装什么。
本章回顾
这一章从历史走到边界。开头那个被关在房间里的人和门缝下的纸条,是理解后面每一节的入口——上下文、工具、权限、子任务,都可以换回纸条来想。
3.1,Agent 收敛成一个自主循环,工程从架构转移到输入。3.2,最小的 Agent 几十行——一个模型、一段提示词、两个工具、一个循环;它能运行,但处理不了真实任务。
上半章让它能处理真实任务。3.3,纸条太多时留什么:摘要写的是交接单,用户原话逐字保留,敢丢是因为完整记录还在存档里。3.4,装得下不等于看得见:系统手里现成的事实,回注不需要额外模型调用,要花钱的判断留给事实拦不住的情况;待办清单是不参与压缩的工作记忆。3.5,工具返回值是方向盘:报错给方向,截断可恢复,读可以粗、写必须细。3.6,打转是可测量的:先把重复的证据注回上下文,事实拦不住时才请观察者。
下半章让它敢交付。3.7,纸条上的禁令只是建议:能用程序验证的边界,在工具执行之前拦;拦下来之后要把原因写回纸条上下文。3.8,另开一间房是一笔账:隔离的收益,减去交接的损耗——瓶颈在转述质量。3.9,顺序等于钱:把变的和不变的分开;压缩会击穿前缀,但两次压缩之间,缓存正常复用。
3.10 收在一个趋势上:内核里补模型短板的那部分会越来越薄,模型每强一代面临删除过去优化的选择;但身体、记忆、边界、分身他给不了自己,项目里正在发生的事也只能靠递进去。工程不会消失,只是从“教他怎么做”挪到“把现场递准”。
下一章进入兼容层。这一章假设房间里坐着的是同一个人,实际上,不同的模型说着不同的方言——让任何一个模型都能坐进这个房间,是另一个工程问题。
https://github.com/openkursar/hello-halo
夜雨聆风