在写完「你不知道的 Claude Code:架构、治理与工程实践」之后,发现自己对 Agent 底层的理解还不够深入,加上团队在 Agent 方向已经有不少业务落地经验,一直缺少一份系统梳理,所以我又把资料、开源实现和自己写的代码一起过了一遍,最后整理成了这篇文章。
这篇文章主要讲 Agent 架构里几块最影响工程效果的内容,包括控制流、上下文工程、工具设计、记忆、多 Agent 组织、评测、追踪和安全,最后再用 OpenClaw 的实现把这些设计原则串起来看一遍。
整理下来,有几处判断和我原来想的不太一样,更贵的模型带来的提升,很多时候没有想象中那么大,反而 Harness 和验证测试质量对成功率的影响更大,调试 Agent 行为时,也应优先检查工具定义,因为多数工具选择错误都出在描述不准确,另外,评测系统本身的问题,很多时候比 Agent 出问题更难发现,如果一直在 Agent 代码上反复调,效果未必明显,读完这篇,这几个问题应该能有些答案。
Agent Loop 的核心实现逻辑抽象后其实不到 20 行代码:
const messages: MessageParam[] = [{ role: "user", content: userInput }];
while (true) {
const response = await client.messages.create({
model: "claude-opus-4-6",
max_tokens: 8096,
tools: toolDefinitions,
messages,
});
if (response.stop_reason === "tool_use") {
const toolResults = await Promise.all(
response.content
.filter((b) => b.type === "tool_use")
.map(async (b) => ({
type: "tool_result" as const,
tool_use_id: b.id,
content: await executeTool(b.name, b.input),
}))
);
messages.push({ role: "assistant", content: response.content });
messages.push({ role: "user", content: toolResults });
} else {
return response.content.find((b) => b.type === "text")?.text ?? "";
}
}
对应的控制流如下,感知 -> 决策 -> 行动 -> 反馈四个阶段不断循环,直到模型返回纯文本为止:
看过不少 Agent 实现和官方 SDK ,结构都差不多,循环本身相当稳定,从最小实现一路扩展到支持子 Agent 、上下文压缩和 Skills 加载,主循环基本没有变化,新增能力通常都是叠加在循环外部,而不是改动循环内部。
新能力基本只通过三种方式接入:扩展工具集和 handler 、调整系统提示结构、把状态外化到文件或数据库,不应该让循环体本身变成一个巨大的状态机,模型负责推理,外部系统负责状态和边界,一旦这个分工确定下来,核心循环逻辑就很少需要频繁调整了。
Anthropic 对这两类系统有一个直接区分:执行路径由代码预先写死的是 Workflow ,由 LLM 动态决定下一步的是 Agent ,核心区别在于控制权掌握在谁手里,现实中很多标着 Agent 的产品,深入看其实更接近 Workflow ,不过两者本身并无高下之分,真正重要的是给任务找到更适合的解决方案。
| 维度 | Workflow | Agent |
|---|---|---|
| 控制权 | 代码预定义,同输入必走同一路径 | LLM 动态决策,可能需要评测验证 |
| 执行方式 | 工具顺序固定,错误走预设分支 | 工具按需选择,模型可尝试自我修复 |
| 状态与记忆 | 显式状态机,节点跳转清晰 | 隐式上下文,状态在对话历史中累积 |
| 维护成本 | 改流程需修改代码并重新部署 | 调整系统提示即可,无需重新部署 |
| 可观测性 | 日志定位节点,延迟可预估 | 需完整执行记录理解决策链,轮数不固定 |
| 人机协作 | 人在预设节点介入 | 人在任意轮次介入或接管 |
| 适用场景 | 流程固定、输入边界清晰 | 需要中间推理与灵活判断 |
放在一张图里看,会更直观:
大多数 AI 系统拆开看,其实都是这五种模式的组合,很多场景并不需要完整的 Agent 自主权,把其中几种模式搭起来就够了,关键还是看任务本身适合哪一种设计。
提示链 Prompt Chaining:任务拆成顺序步骤,每步 LLM 处理上一步的输出,中间可加代码检查点,适合生成后翻译、先写大纲再写正文这类线性流程。
路由 Routing:对输入分类,定向到对应的专用处理流程,简单问题走轻量模型,复杂问题走强模型,技术咨询和账单查询走不同逻辑。
并行 Parallelization:两种变体:分段法把任务拆成独立子任务并发跑,投票法把同一任务跑多次取共识,适合高风险决策或需要多视角的场景。
编排器-工作者 Orchestrator-Workers:中央 LLM 动态分解任务,委派给工作者 LLM ,综合结果,nanobot 的 spawn 工具和 learn-claude-code 的子 Agent 模式都是这个原型。
评估器-优化器 Evaluator-Optimizer:生成器产出,评估器给反馈,循环直到达标,适合翻译、创意写作这类质量标准难以用代码精确定义的任务。
Harness 是指围绕 Agent 构建的测试、验证与约束基础设施,这里的 Harness 至少包括四个部分:验收基线、执行边界、反馈信号和回退手段。
模型虽然重要,但决定系统能不能稳定运行的,往往是这些外围工程条件。这个判断在代码编写这类高可验证任务上最成立,但在开放式研究、多轮协商这类弱验证任务里,模型上限本身仍然更关键。
3 个工程师 5 个月写了百万行代码,将近 1500 个 PR ,是传统开发速度的 10 倍。这个速度背后不是模型有多强,而是几个工程决策做对了:
AGENTS.md 只保留约 100 行作为索引,细节拆到各 docs 目录按需引用。APP 把日志、指标、追踪三路数据经由 Vector 分发到 Victoria 存储层,对应 LogQL 、PromQL 、TraceQL 三个查询接口,Codex 通过这三个接口查询、关联、推理,完成改动后重启应用、重跑工作负载,结果再打回给 Codex ,UI Journey 也作为输入接入。整套可观测性栈按任务临时创建、任务完成即销毁,Agent 不需要等人告知错误,直接查询系统状态验证修改是否生效。
图里用任务清晰度和验证自动化程度把任务分成四种状态,右上角目标明确、结果可以自动验证,是最适合 Agent 发挥的区域,左上角任务清楚但验收还得人盯,吞吐量天花板是人的审查速度,右下角有自动化反馈但目标模糊,系统会高效地往错误方向跑,左下角两者都缺,Agent 基本起不到作用。
Harness 要做的就是把任务推进右上角,让对错有机器可以执行的判断标准,而不是靠人盯。
Transformer 的注意力复杂度是 $O(n^2)$,上下文越长,关键信号越容易被噪声稀释,实践里最常见的失效模式是无关内容一旦占到上下文的大头,Agent 的决策质量就会明显下滑,这类现象通常被叫作 Context Rot ,很多看起来像模型能力不足的问题,往往可以追溯到上下文组织不当。
问题通常不是窗口不够长,而是信息密度不对,偶尔用的东西每次都加载进来,稳定的规则和动态的状态混在一起,模型能看到的内容越来越多,但真正有用的部分越来越难被注意到。
解决方式是按信息的使用频率和稳定性分层管理,每层只放自己该放的东西:
MEMORY.md,不直接进系统提示,需要时才读取别把确定性逻辑放进上下文,凡是可以通过 Hooks 、代码规则或工具约束表达的内容,都应交给外部系统处理,而不是让模型反复读取。
| 策略 | 成本 | 丢什么 | 适用场景 |
|---|---|---|---|
| 滑动窗口 | 极低 | 早期上下文 | 简短对话 |
| LLM 摘要 | 中 | 细节,保留决策 | 长任务、含关键决策 |
| 工具结果替换 | 极低 | 工具原始输出 | 工具调用密集型 |
滑动窗口实现最简单,但会丢掉早期决策背景。LLM 摘要的进阶做法是 branch summarization ,摘要时明确保留架构决策、未完成任务和关键约束。工具结果替换里,micro_compact 每轮替换旧工具输出,auto_compact 在上下文超阈值时自动触发。
LLM 推理时,Transformer attention 会为每个 token 计算 Key-Value 对,如果当前请求的输入前缀和之前某次请求完全一致,这部分 KV 就不需要重新计算,直接从缓存读取,这就是 Prompt Caching 的底层原理。命中的前提是精确前缀匹配,不是内容相似就能触发,任何一个 token 不同都会破坏匹配,所以缓存友好的设计核心是稳定性,系统提示、工具定义、长文档这类在多轮请求里基本不变的内容天然适合缓存,动态信息(当前时间、用户输入、工具调用结果)放在后面,不影响前缀的稳定性。
这和上下文分层设计直接相关。常驻层越稳定,前缀命中率越高,边际成本越低,所以「常驻层短而稳定」不只是为了节省 token ,也在保护缓存命中。Skills 延迟加载的好处也在这里,按需注入的内容不破坏系统提示前缀,而是追加在稳定前缀之后,工具定义同样参与缓存计算,接了很多 MCP 工具的 Agent 如果工具集频繁变动,缓存命中就会不断失效。有一个反直觉的地方:稳定的大系统提示,比频繁变动的小提示实际成本更低,因为写入成本只付一次,后续每次调用读取的折扣可以达到 90%。
Skills 是上下文工程里非常有效的一种模式,核心思路是:系统提示只保留索引,完整知识按需加载。
const systemPrompt = `
可用 Skills:
- deploy: 部署到生产环境的完整流程
- code-review: 代码审查检查清单
- git-workflow: 分支策略和 PR 规范
`;
async function executeLoadSkill(name: string): Promise<string> {
return fs.readFile(`./skills/${name}.md`, "utf-8");
}
Skill 描述要足够短,避免常驻上下文持续涨 token ,也要足够像路由条件而不是功能介绍,至少说明什么时候用、什么时候不要用、产出物是什么,最直接的写法是 Use when / Don't use when 再补几条反例,很多路由失败不是模型能力问题,而是边界写得不清楚。系统提示里也要把调用规则写明确:每次回复前先扫描 available_skills,有明确匹配时再读取对应 SKILL.md,多个匹配时优先选最具体的那个,没有匹配就不读取,一次只加载一个。
图里的数据很直接:没有反例时准确率从基准 73% 掉到 53%,加上反例后升到 85%,响应时间还降了 18.1%。反例不是可选项,是 Skill 描述能不能起作用的关键。
Skills 不能等 Agent 想起来再用,要每轮都先扫描描述,但扫描成本要足够低,实际加载数量也要受控,如果 Skill 会触发外部 API 写操作,系统提示里应显式补充速率限制要求,尽量批量写入、避免逐条循环、遇到 429 主动等待。
Skill 描述符有两个写法陷阱值得单独说。第一个是字数:
# 低效(约 45 tokens )
description: |
This skill handles the complete deployment process to production.
It covers environment checks, rollback procedures, and post-deploy
verification. Use this before deploying any code to production.
# 高效(约 9 tokens )
description: Use when deploying to production or rolling back.
路由准确率差距不大,但每个启用的 Skill 描述符都常驻上下文,Skill 一多,长描述的累积成本很可观。第二个是精度:描述太短(help with backend)等于任何后端工作都能触发,路由会乱。真正有效的描述符是路由条件,不是功能介绍,"何时该用我"比"我能做什么"重要得多。
数量上同样要控制:常驻系统提示的只放高频 Skill ,低频的不要塞进默认列表,需要时再手动引入,极低频的直接用文档替代就够了,不必做成 Skill 。几个典型反模式:正文几百行工作手册全塞进 Skill 正文而不是拆成 supporting files ;一个 Skill 试图覆盖 review 、deploy 、debug 、incident 五件事;有副作用的 Skill 没有显式限制调用时机。这三个问题都会让 Skill 路由失准,而且很难排查。
Skills 和 MCP 在上下文成本上的特征并不相同,很多 MCP 会把完整结果直接返回给模型,更容易迅速吃掉上下文预算,CLI + 单句描述的 Skill 更接近模型熟悉的调用方式,在大多数可过滤、可拼接的数据读取任务里也更简洁,当然 MCP 也有明确适用场景,例如 Playwright 这类需要维护状态的任务。
压缩阶段最常见的问题,不是摘要不够短,而是保留顺序设错了,LLM 通常会优先删除那些看起来还可以重新获取的信息,早期的 tool output 通常最先被移除,但与之相关的架构决策、约束理由和失败路径也很容易一并丢失。最好在 CLAUDE.md 或等价文档里明确写出压缩时的保留优先级:
### Compact Instructions 如何保留关键信息
保留优先级:
1. 架构决策,不得摘要
2. 已修改文件和关键变更
3. 验证状态,pass/fail
4. 未解决的 TODO 和回滚笔记
5. 工具输出,可删,只保留 pass/fail 结论
压缩时还有一条容易踩的坑:不要改动标识符,UUID 、hash 、IP 、端口、URL 、文件名这类值必须原样保留,一旦把 PR 编号或 commit hash 改错一位,后续工具调用就会直接失效。
Cursor 把这种方式叫 Dynamic Context Discovery ,默认少给,只在需要时读取。文件系统天然适合做这个接口,工具调用经常返回大量 JSON ,几次搜索就能堆出成千上万 token ,不如直接写入文件,让 Agent 通过 grep 、rg 或脚本按需读取,工具写文件,Agent 读文件,开发者也可以直接查看。
Cursor 在 MCP 工具上也验证过这个方向:他们把工具描述同步到文件夹,Agent 默认只看到工具名,需要时再查询具体定义,A/B 测试中,调用 MCP 工具的任务总 token 消耗减少了 46.9%。
同样的思路也适用于长任务压缩,压缩触发时,不直接丢弃历史,而是把聊天记录完整保留为文件,摘要里只引用文件路径,后续如果 Agent 发现摘要缺少细节,仍然可以回到历史文件里检索,这样压缩就变成了一种有损但可追溯的操作,而不是一次不可恢复的硬截断。
上下文决定模型能看到什么,工具决定模型能做什么。工具定义的质量比数量更关键,仅 5 个 MCP 服务器就可能带来约 55,000 tokens 的工具定义开销,相当于在 200K 上下文里还没开始对话就用掉了近三成,工具一旦过多,模型对单个工具的注意力也会被稀释。
工具问题多数不在数量不够,而在选不对、描述看不懂、返回一堆没用的、出了错 Agent 也不知道怎么改。
| 维度 | 好工具 | 差工具 |
|---|---|---|
| 粒度 | 对应 Agent 要完成的目标 | 对应 API 能做的操作 |
| 示例 | update_yuque_post |
get_post + update_content + update_title |
| 返回 | 与下一步决策直接相关的字段 | 完整原始数据 |
| 错误 | 结构化,含修正建议 | 通用字符串 "Error" |
| 描述 | 说明何时用、何时不用 | 只写功能说明 |
工具设计大致经历了三个阶段,早期做法是直接把现有 API 封装成工具扔给模型,后来发现模型选错工具,问题不在模型能力,而在工具本身的设计视角就错了,原来是给工程师设计的,不是给 Agent 设计的。
第一代,API 封装:每个 API Endpoint 对应一个工具,粒度过细,Agent 往往需要协调多个工具才能完成一个目标。
第二代,ACI ,即 Agent-Computer Interface:工具应对应 Agent 的目标,而不是底层 API 操作,不要只给一个像 update(id, content) 这样的通用接口,而是直接给一个 update_yuque_post(post_id, title, content_markdown),一次把目标动作说完整。
第三代,Advanced Tool Use:在工具设计之上,进一步优化工具的发现、调用和描述方式,主要包括三个方向:
Tool Search ,动态工具发现:别把全部工具定义一次性塞给模型,Agent 通过 search_tools 按需发现工具定义,上下文保留率可达到 95%,Opus 4 的准确率也从 49% 提升到 74%。
Programmatic Tool Calling ,代码编排:别让中间数据一轮轮穿过模型,而是让模型用代码编排多个工具调用,中间结果在执行环境中流转,不进入 LLM 上下文,token 消耗可从约 150,000 降到约 2,000 。
Tool Use Examples ,示例驱动:每个工具附带 1-5 个真实调用示例,JSON Schema 只能描述参数类型,但无法表达调用方式,加入示例后,工具调用准确率可从 72% 提升到 90%。
类比 HCI 对人的影响,工具设计对 Agent 的影响一样直接,不能只看「工具能不能调用」,还要看「调用错了之后能不能自己修回来」。
三个原则放在一起看更清楚,差的做法参数模糊、错误不可修正、定义实现分离:
// 差:参数模糊,出错只返回字符串,Agent 不知道怎么修正
const tool = {
name: "update_yuque_post",
input_schema: {
properties: {
post_id: { type: "string" },
content: { type: "string" },
},
},
};
// 出错时
return "Error: update failed";
好的做法用 betaZodTool 把定义和实现绑在一起,参数描述直接约束格式,错误结构化给出修正建议:
const updateTool = betaZodTool({
name: "update_yuque_post",
description: "更新语雀文章内容,不适合创建新文章",
inputSchema: z.object({
post_id: z.string().describe("语雀文章 ID ,纯数字字符串,如 '12345678'"),
title: z.string().optional().describe("文章标题,不改时可省略"),
content_markdown: z.string().describe("Markdown 格式正文"),
}),
run: async (input) => { // input 类型自动推导,问题尽量在编译期暴露
const post = await getPost(input.post_id);
if (!post) throw new ToolError("文章 ID 不存在", {
error_code: "POST_NOT_FOUND",
suggestion: "请先调用 list_yuque_posts 获取有效的 post_id",
});
return await updatePost(input.post_id, input.title, input.content_markdown);
},
});
左边是差工具设计,工具只说自己能做什么,不说明什么时候该用、什么时候不该用,结果是 Agent 容易选错工具、填错参数,报错后不断重试绕圈,右边是符合 ACI 原则的工具设计,边界清楚、结构化错误给出修正建议,Agent 更容易一次选对,失败后也能快速修正。
调试 Agent 时应先检查工具定义,大多数工具选择错误的原因出在描述不准确,不在模型能力,工具数量也要克制,能用 Shell 处理的、只需静态知识的、更适合 Skill 的,都不需要新增工具。
框架运行过程中会产生一些内部事件:压缩发生了、通知推送了、某个工具调用被跳过了,这些事件需要记在会话历史里,但不应该直接进 LLM ,否则模型会看到一堆它不理解的字段,白白消耗 token 。
解决方式是在框架层分两种消息类型:给应用层用的 AgentMessage 可以携带任意自定义字段,真正发给 LLM 的 Message 只保留 user、assistant、tool_result 三种标准类型,调用前过滤一遍,会话历史保留完整框架状态,LLM 只收它需要的部分。
Agent 不具备原生的时间连续性,会话结束后,上下文随之清空,下一次启动时也不会自动保留此前状态,要让系统具备跨会话的一致性,记忆层得单独设计,对 Agent 来说它是一层基础设施,不是可以事后补上的能力。
这里不是按存储介质来分,而是按 Agent 实际要解决的问题来分:
MEMORY.md,语义记忆:Agent 主动写入认为重要的事实,每次启动时注入系统提示左侧是 Agent 运行时,只有上下文窗口存在于 messages[] 中,会随着会话结束一起清空,右侧是磁盘上的持久层,Skills 文件按需加载,JSONL 会话历史保留完整过程并支持检索,MEMORY.md 则沉淀 Agent 主动写入的稳定事实,并在后续会话中持续注入。
MEMORY.md 和 Skills 如何协作实际系统实现方式不同,但核心都在解决两件事:重要事实要留下来,注入模型的内容又不能失控。
ChatGPT 四层记忆
拿它当一个产品实现来看,它没有使用向量数据库,也没有引入 RAG 检索增强生成,整体结构比很多人的预期更简洁:
| 层 | 内容 | 持久化 |
|---|---|---|
| Session Metadata | 设备、地点、使用模式 | 否,会话级 |
| User Memory | 约 33 条关键偏好事实 | 是,每次注入 |
| Conversation Summary | 约 15 个最近对话的轻量摘要 | 是,摘要预生成 |
| Current Session | 当前对话滑动窗口 | 否 |
OpenClaw 混合检索
memory/YYYY-MM-DD.md,追加写日志,保留原始细节MEMORY.md,精选事实,Agent 主动维护memory_search,70% 向量相似度 + 30% 关键词权重的混合检索这个设计的好处是可读、可改、可检索,Markdown 文件可以直接查看和修订,搜索时按相关性拉取需要的内容,而不是把全部记忆一次性塞进上下文,对大多数 Agent 来说,记忆库规模并不需要一开始就引入向量存储,结构化 Markdown 加关键词搜索已经具备足够好的可调试性、可维护性和成本表现,只有当规模超过几千条、并且确实需要语义相似度检索时,再考虑引入向量检索会更合适。
有了记忆分层之后,下一步要处理的就不是「要不要存」,而是「什么时候整合,以及整合失败怎么办」。
这张图强调的不是「把旧消息删掉」,而是把它们从活跃上下文中安全移出,左边是持续增长的对话消息流,中间用 tokenUsage / maxTokens >= 0.5 作为触发阈值,达到阈值后,成功路径会先对待整合消息做 llmSummarize(toConsolidate),再把摘要追加到 MEMORY.md,最后只更新 lastConsolidatedIndex,失败路径则把原始消息写入 archive/,保留完整历史,避免整合失败时丢失上下文。
这里说的自主度,不是少几次人工确认,而是让 Agent 能在更长时间跨度内稳定推进任务,前提也不是直接放权,而是先补齐三类基础设施:跨 session 续跑、单个 session 内的进度约束,以及慢速 I/O 的后台接入。
长任务最常见的失败,不是单步报错,而是 session 结束时任务还没做完,即使启用 compaction ,也挡不住两类问题:一是在单个 session 里试图做完整个应用,结果上下文先耗尽,二是只做完一部分,下一轮又无法准确恢复现场,过早判断完成。
更稳定的做法,是把长任务拆成 Initializer Agent 和 Coding Agent 两个角色协作,这种模式最适合代码生成、应用搭建、重构迁移这类单个 session 做不完、但又能拆成一批可验证子任务的工作。
Initializer Agent 只在第一轮运行一次,负责生成 feature-list.json、init.sh、初始 git commit 和 claude-progress.txt,先把任务变成可持久化的外部状态,后面的多个 session 由 Coding Agent 循环执行,每次从 claude-progress.txt 和 git log 恢复现场,定位当前任务,实现一个功能,跑测试,更新 passes 字段,提交代码后退出,这样即使中途崩溃,也能直接从文件系统里的状态继续,而不是从头再来。
进度要放在文件里,不要放在上下文里,功能清单用 JSON ,不用 Markdown ,结构化格式更适合模型稳定修改,当 feature-list.json 里所有功能都变成 passes: true,任务才算完成。
又又又放不下了,可以去这里阅读我的原文,当然假如你觉得我的内容还行,也可以关注我
1
zisen 3 月 30 日
bro 说点大伙不知道的,这种在知乎都看烂了
|
3
qq007523 3 月 30 日
概念、方法论、设计模式大家都懂,有 github demo 看看嘛
|
4
Paul0218 3 月 30 日
多发,爱吃
|
5
Ziuc 3 月 31 日
配图很漂亮,请问是用什么工具绘制的?
|
6
heyshark 3 月 31 日
正好在学习大模型方法论相关知识,先点个赞再看
|