← 返回文章列表

AI 应用框架到底分几层:从一个 while 循环讲到生产系统

大多数人学 Agent 框架,是从“中间件”这个词开始卡住的。
AI Agent 2026-10-24 00:13:01 评论 0

AI 应用框架到底分几层:从一个 while 循环讲到生产系统

拆的是这样一张能力地图: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

五层,下面逐层拆。


一、内核:Agent 就是一个 while 循环

先看这个东西,去掉注释不到五十行,没有任何封装:

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);
}

一句话记住:

  • node hook 是“在旁边改剧本”——你能往 state 里加东西、删东西,但戏照演。
  • wrap hook 是“把这段戏包起来”——你可以换掉演员、改台词、甚至直接掐掉不演。

判断一个需求该用哪种钩子,就问一句:我需不需要拦住它? 需要 → 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 把大块内容挪出上下文,不影响调用参数

看懂这张表,第三层就不用背了。

一个真实的坑:改 state 是免费的,改坏了也没人拦你

上一节说 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 那套设计的钥匙。它看起来给了你三个功能:

  • 先计划再行动:把 todo list 写进 state,而不是指望模型“记得自己要做什么”
  • 文件后端:把大段中间产物写到文件里,上下文只留一句“已写入 plan.md”
  • 异步子代理:把探索过程关在子上下文里,只带回结论

但这三件事在干同一件事:给主上下文减负。

计划是“把意图从上下文里外包出去”,文件系统是“把内容从上下文里外包出去”,子代理是“把过程从上下文里外包出去”。

搞懂这一点,你就不会再问“deepagents 和普通 agent 有什么区别”了——它是同一套内核 + 一组专门用来省上下文的中间件。 地图上那个“验收和评审”标签也是同理:把评审做成一个独立子代理,让它的挑刺过程不污染主上下文。


七、最外圈:为什么工程化不算“一层”

地图最外面是身份认证、接口授权、资源隔离、后端路由、多模态消息。

它们不是第六层,因为它们不改变 agent 的内部逻辑,但每一个都能让 agent 上不了线。

有意思的是,它们和里面几层是一一呼应的:

  • 资源隔离 ← 第四层沙箱的动机。不是“技术上想隔离”,是“合规要求不隔离就过不了审”。
  • 后端路由 ← 第五层路由模式的动机。一个是“选哪个 agent”,一个是“选哪个后端”,同一个问题在两个尺度上出现。
  • 身份认证 / 接口授权 ← 第三层“文件读写权限”的动机。工具权限只是内部版,用户权限才是外部版。

这个呼应关系是我看这张图最有收获的地方:很多工程化需求,在架构内部早就有一个技术版本。 反过来也成立——你在内部做的每一个权限设计,最终都会在外面以合规要求的形式再出现一次。


八、什么时候你不需要框架

不需要:单轮问答 + 一两个工具。直接写那四十几行,比学一套抽象快得多,而且出了问题你完全知道在哪。

需要:出现下面任意一条。

  • 要中断等人(几小时后再接着跑)——手写意味着自己搞持久化 + 状态机
  • 要跨会话记忆
  • 要流式输出到前端
  • 要多个 agent 协作
  • 要多人维护同一套 agent 逻辑——框架本质上是团队约定

最后一条经常被忽略。中间件的真正价值不是省代码,是给“哪些逻辑该写在哪”提供了一个所有人都认的答案。当团队里五个人都在改同一个 agent 时,这个答案比性能重要。


评论: