拆的是这样一张能力地图:LangChain v1 的
createAgent+ 中间件机制,往外延伸到沙箱和多智能体。文中的代码和行数都来自可运行的 demo,实测环境langchain@1.5.19。
大多数人学 Agent 框架,是从“中间件”这个词开始卡住的。
因为它看起来像魔法:你往数组里扔一个对象,框架就在“某个时刻”把它调起来。你不知道那个时刻是哪一刻,于是只能背文档。
但如果你先看清最底下那层——它只是四十几行的 while 循环——中间件立刻就不神秘了:它只是在循环的固定位置开了几个插座。
整张地图长这样。内核坐在中间——往上长是扩展和编排,往下沉是执行环境:
flowchart TB
L5["⑤ 多智能体编排<br/>先计划再行动 · 路由 · handoff · 异步子代理"]
L3["③ 中间件应用<br/>PII 检测 · HITL 审批 · 文件权限 · skill · MCP · Memory · 摘要"]
L2["② 扩展机制<br/>中间件 = node hook + wrap hook"]
L1["① 内核<br/>agent loop · response format · tool 消息补全"]
L4["④ 执行环境<br/>文件后端 · 沙箱选型 · 沙箱挂载点 · 解释器"]
X["跨切面<br/>身份认证 · 接口授权 · 资源隔离 · 后端路由 · 多模态消息"]
L1 --> L2 --> L3 --> L5
L1 --> L4
X -.-> L2
X -.-> L4
X -.-> L5
五层,下面逐层拆。
先看这个东西,去掉注释不到五十行,没有任何封装:
const messages = [
{ role: 'system', content: '你是一个会使用工具的助手。' },
{ role: 'user', content: input },
];
for (let step = 1; step <= 10; step++) {
const reply = await model(messages); // 1. 问模型
messages.push(reply); // 2. 它的"意图"也进历史
if (!reply.tool_calls?.length) return reply.content; // 3. 没有工具调用 = 说完了
for (const call of reply.tool_calls) { // 4. 有工具调用 = 本地执行
const result = await tools[call.name].run(call.args);
messages.push({ role: 'tool', tool_call_id: call.id, content: result });
}
}
就这些。Deep Agents、CrewAI、AutoGen,剥掉所有装饰,跑的都是这个。
记住三个不变量,后面所有概念都从它们推导出来:
第一,messages 是唯一的状态。 没有别的地方存“记忆”。所谓“记忆系统”,说到底就是“这个数组怎么持久化、怎么裁剪、怎么检索”。
第二,模型不执行任何东西。 它只会输出一段结构化文本:“我想调 calculator,参数是 {a: 12, b: 34}”。真正干活的是你本地那个 map 里的函数。这个边界划清了,你才会明白:工具安全必须在你的代码里做,在 prompt 里写“请不要删库”是没用的。
第三,工具结果必须回灌。 那个 role: 'tool' 的消息不是日志,它是模型下一轮能不能看见结果的唯一途径。忘了回灌,模型就会一直重复调同一个工具——这是新手最常见的“死循环”。
第一版跑通了。第二周产品经理来了。
需求一:删文件之前要人工确认。
需求二:手机号不能发给模型。
需求三:上下文超过一定长度要压缩。
需求四:简单任务用便宜模型,复杂任务用贵的。
需求五:每次工具调用都要打日志、算耗时。
天真的做法是全都塞进循环:
for (let step = 0; step < 10; step++) {
messages = maskPii(messages); // 需求 2
if (countTokens(messages) > 8000) messages = await summarize(messages); // 需求 3
const model = pickModel(messages); // 需求 4
console.log('[call]', model.name); // 需求 5
const reply = await model.invoke(messages);
for (const call of reply.tool_calls) {
if (call.name === 'delete_file' && !(await askHuman(call))) continue; // 需求 1
// ...
}
}
问题不在于它变长了。问题在于它耦合了。
每一个新需求都要改这个函数,每一个新需求都可能改坏别人。上周加完脱敏,这周发现摘要把脱敏后的内容又还原了——因为两段代码都以为自己拥有 messages。
而这些逻辑和“这个 agent 到底要干什么”毫无关系。它们叫横切关注点(cross-cutting concerns)——不管你写的是客服 agent 还是编码 agent,这些事都要做,而且做法一样。
面向对象时代,这种东西靠 AOP 解决。Agent 时代,它叫中间件。
中间件不是“一个回调”,是一组按位置划分的钩子。LangChain v1 定义了六个:
| 钩子 | 触发时机 | 类型 |
|---|---|---|
beforeAgent |
整个任务开始前,一次 | node hook |
beforeModel |
每轮模型调用前 | node hook |
wrapModelCall |
包住这次模型调用 | wrap hook |
afterModel |
拿到回复后 | node hook |
wrapToolCall |
包住这次工具调用 | wrap hook |
afterAgent |
任务结束后,一次 | node hook |
六个名字里,真正需要理解的是两类的差别。分界线只有一条:能不能“不执行”。
// node hook:只能改 state,请求该发还是要发
beforeModel: (state) => {
if (state.messages.length > 50) {
state.messages = [state.messages[0], ...state.messages.slice(-20)];
}
}
// wrap hook:不调 handler,这件事就没发生
wrapToolCall: async (request, handler) => {
if (request.toolCall.name === 'delete_file' && !(await ask())) {
return new ToolMessage({ content: '用户拒绝', tool_call_id: request.toolCall.id });
}
return handler(request);
}
一句话记住:
判断一个需求该用哪种钩子,就问一句:我需不需要拦住它? 需要 → wrap;只是调整输入输出 → node。
这个判断标准比背文档管用。因为很多“功能”看起来是新东西,其实只是钩子里的一个字段。
比如 dynamic model。听起来像独立能力,实际全部实现是这样:
wrapModelCall: (request, handler) => {
const wanted = /代码|重构/.test(lastText) ? bigModel : smallModel;
return handler({ ...request, model: wanted });
}
就这么几行。dynamic prompt 同理——改的是 request.systemPrompt。理解了这一层,你在文档里看到十几个“特性”就不会再慌,它们都是同一套钩子的不同用法。
中间件数组的顺序,等于洋葱圈从外到内的顺序:
flowchart LR
A["用户消息"] --> B["summarize<br/>beforeModel"]
B --> C["piiGuard<br/>wrapModelCall"]
C --> D["dynamicModel<br/>wrapModelCall"]
D --> E["模型"]
这一步很容易被当成“无所谓”,但它会咬人。
假设你把 logger 放在 piiGuard 外层:它先执行,看到的还是没脱敏的原文,于是明文手机号进了日志——合规部门会来找你。
反过来把 logger 放到内层:它执行时 piiGuard 已经改过请求了,日志里是干净的,但排查线上问题时你也看不到用户到底说了什么。
所谓“可观测性 vs 合规”的架构权衡,落到代码上就是数组里两个元素换个位置。
地图的第三层列了一长串标签,看着像十几个互不相干的功能。实际上每一个都在回答同一个问题:用哪种钩子,插在哪儿。
| 需求 | 该用的钩子 | 为什么不能用另一个 |
|---|---|---|
| PII 脱敏 | wrapModelCall |
只改“要发出去的那份副本”,state 保留原文 |
| 上下文摘要 | beforeModel |
只是调整消息列表,不需要拦请求 |
| 动态 prompt / 动态 model | wrapModelCall |
要改的正是这一次 request |
| HITL 审批 | wrapToolCall |
必须能“不执行”,node hook 做不到 |
| 文件读写权限 | wrapToolCall |
同上,而且要能看到具体的路径参数 |
| skill / MCP 工具注入 | wrapModelCall |
改的是“这次请求里能看到哪些工具” |
| Memory | beforeAgent + afterAgent |
任务的开始和结束各一次,中间不掺和 |
| 消息卸载 | beforeModel |
把大块内容挪出上下文,不影响调用参数 |
看懂这张表,第三层就不用背了。
上一节说 node hook “只能改 state”。这句话有歧义——它听起来像“只能做小事”,实际是“能做很危险的事,而且没有护栏”。
我手写摘要中间件的时候就踩了:折叠历史时,把一条 role: 'tool' 的消息留下了,却把它的主人——那条带 tool_calls 的 assistant 消息——折进了摘要。
跑出来是这样(省略了本轮新增的对话):
[0] system
[1] assistant (更早的 2 条消息已折叠为摘要)
[2] tool tool_call_id=call_1 12 × 34 = 408 ← 孤儿
[3] assistant 12 × 34 = 408
[4] user 谢谢
[2] 那条 tool 消息的 tool_call_id 指向 call_1,但包含 call_1 的那条 assistant 消息已经不存在了。真实 API 会直接返回 400:messages with role 'tool' must be a response to a preceding message with 'tool_calls'。
这个 bug 的恶劣之处在于它的触发条件:只有当“历史上恰好发生过工具调用,且这次调用恰好被折叠边界切到”时才会出现。测试环境跑一百次都不会复现。
所以地图上那个 “tool 消息补全” 标签不是可选项,它是上下文管理的必要补丁。官方的 summarizationMiddleware 文档里专门写了一句 “ensuring AI/Tool message pairs remain together”——这句话不是功能描述,是它存在的理由之一。
这也解释了框架的价值:它把“你已经知道的坑”变成了“不用再想的事”。
前三层都在讲 agent 内部。第四层换了个问题:工具到底在哪儿执行?
一个只会查天气的 agent 不需要沙箱。但让 agent 帮你改代码、跑测试、启动 dev server,就必须回答了——因为它会在某台机器上执行 rm -rf。
三个概念要分清,很多人是在这里绕晕的:
文件后端(filesystem backend):Agent 眼里的文件系统抽象。它可能是一个内存里的 Map,可能是宿主机上的真实目录,也可能在远端容器里。Agent 只看到 /workspace/src/app.js 这样的虚拟路径。
沙箱:真正执行代码的隔离环境。容器、microVM、或者云厂商提供的托管沙箱。
挂载点:把前两者缝起来的那根线。
flowchart LR
A["Agent 看到的虚拟路径<br/>/workspace/src/app.js"] -->|"沙箱挂载点"| B["容器内 /workspace"]
C["宿主机真实目录<br/>~/sessions/abc123/src/app.js"] -->|"bind mount"| B
为什么要拆成三个而不是一个?因为它们的生命周期不一样。
沙箱应该是一次性的——每轮对话重建一个,上一个的痕迹全部丢弃,这样最安全。但文件后端不能是一次性的,否则第 5 轮找不到第 2 轮写的那个文件。
这两句话合起来就是沙箱设计的核心矛盾:沙箱要能随便销毁,数据必须活下来。 挂载点就是解决这个矛盾的装置——容器没了,/workspace 里挂的东西还在宿主机上。
选 E2B 还是阿里云沙箱,本质是选“你信任谁的隔离”以及“你的用户和数据在哪个网络位置”。真正要看的是四件事:冷启动延迟(用户能不能等)、数据是否出境(合规)、计费粒度(按秒还是按次)、以及能不能把端口暴露出来——最后一条直接决定下一节。
最后一公里:产物在线预览。
沙箱里跑起来的 dev server 监听 127.0.0.1:3000,用户的浏览器看不到它。要么做端口映射,要么把构建产物传到对象存储再给 URL。
很多人做到“agent 能改代码”就以为完事了,卡在“用户看不到结果”这一步。而地图上的“服务端 Coding Agent + 产物在线预览”是一个整体——没有预览,前面对 Agent 的所有投入都交付不出去。
第五层是最容易被讲玄的。三种模式,各种文章讲得天花乱坠,但如果抓住一个东西,它们立刻变得简单:
多智能体的核心不是“多个模型”,是“多个上下文”。
| 模式 | 上下文怎么流动 | 适合什么 |
|---|---|---|
| 路由(routing) | 分类器选一个专家,只有被选中的那个看到任务 | 任务类型明确、互斥 |
| handoff | 把控制权连同上下文一起交出去,交接后原 agent 退出 | 多阶段流水线,如“售后 → 退款” |
| 异步子代理 | 父代理只拿回结论,子代理的中间过程不进父上下文 | 调研、搜索、长链路探索 |
为什么需要“多个上下文”?因为单 agent 做长任务会失败,而失败的原因通常不是模型不够聪明,是上下文被污染——中间过程的 200 条工具日志,把最初那句“帮我把登录模块从 session 改成 JWT”挤到了九霄云外。
这就是 deepagents 那套设计的钥匙。它看起来给了你三个功能:
但这三件事在干同一件事:给主上下文减负。
计划是“把意图从上下文里外包出去”,文件系统是“把内容从上下文里外包出去”,子代理是“把过程从上下文里外包出去”。
搞懂这一点,你就不会再问“deepagents 和普通 agent 有什么区别”了——它是同一套内核 + 一组专门用来省上下文的中间件。 地图上那个“验收和评审”标签也是同理:把评审做成一个独立子代理,让它的挑刺过程不污染主上下文。
地图最外面是身份认证、接口授权、资源隔离、后端路由、多模态消息。
它们不是第六层,因为它们不改变 agent 的内部逻辑,但每一个都能让 agent 上不了线。
有意思的是,它们和里面几层是一一呼应的:
这个呼应关系是我看这张图最有收获的地方:很多工程化需求,在架构内部早就有一个技术版本。 反过来也成立——你在内部做的每一个权限设计,最终都会在外面以合规要求的形式再出现一次。
不需要:单轮问答 + 一两个工具。直接写那四十几行,比学一套抽象快得多,而且出了问题你完全知道在哪。
需要:出现下面任意一条。
最后一条经常被忽略。中间件的真正价值不是省代码,是给“哪些逻辑该写在哪”提供了一个所有人都认的答案。当团队里五个人都在改同一个 agent 时,这个答案比性能重要。