吃透 AI Agent 开发 · 第 01 篇 · 第一章 · 认知校准
先不急着选框架,沿着一次读取文件的请求,认识入口、上下文、Loop、工具、状态、界面和观测七层边界。
你可能见过这样的 Agent 入门代码:把用户问题发给模型;如果模型要调用工具,就执行工具并把结果塞回去;如此循环,直到模型给出答案。十几行代码,确实能跑。
可一旦用户说“帮我读一下项目配置,修改代码,跑测试,再把结果记到会话里”,十几行代码很快就不够了。模型怎么知道当前目录?工具能不能访问任意文件?执行失败后重试几次?聊了两小时之后,旧消息放不下怎么办?这些问题才是 Agent 开发真正开始的地方。
这一篇先不钻源码。我们先搭一张地图,看看一个能长期工作的 Agent 通常由哪些层次组成。
先把 Agent 想成一家外卖餐厅
用户点了一份餐,不是把订单直接扔给厨师就结束了。
- 前台接单,确认地址和备注。
- 调度台判断交给哪个档口。
- 厨师按照订单工作,需要时去仓库取原料。
- 仓库有领用规则,危险物品不能随便拿。
- 订单系统记录进度,出了问题能追查。
- 配送端把状态持续告诉用户。
模型更像其中负责判断和决策的厨师,而不是整家餐厅。一个 Agent 系统还要负责接收输入、准备上下文、提供工具、保存状态、控制风险和展示过程。
这个类比也有边界:厨师知道自己正在做什么,模型却只看得到本次请求里提供的信息。没有被放进上下文的文件、规则和历史,对模型来说就像从未存在过。
七个常见层次
可以先用下面这条主链理解 Agent:
用户输入
-> 入口与编排
-> 上下文构建
-> Agent Loop
-> 模型请求
-> 工具执行
-> 状态与观测
-> 界面输出
1. 入口与编排
入口负责回答“这次请求应该怎么启动”。它可能来自命令行、网页、聊天窗口,也可能是定时任务。这里通常会解析参数、选择模型、恢复会话、注册工具,然后把准备好的依赖交给核心循环。
入口不应该亲自处理模型的每一个流式片段。否则换一个界面,就得重写整套执行逻辑。
2. 上下文构建
模型没有自动读取项目的能力。系统提示词、当前任务、相关文件、历史消息、可用工具说明,都要由上下文层挑选并组装。
重点不是“塞得越多越好”,而是“这一轮真正需要什么”。上下文越杂,模型越难抓住重点,成本和延迟也会跟着上涨。
3. Agent Loop
Agent Loop 是模型和工具之间的闭环。它负责把消息发给模型,接收文本或工具调用,执行工具,再把结果交回模型。
一个最小版本大概长这样:
while (!done) {
const response = await model.generate({ messages, tools })
if (response.toolCalls.length === 0) {
return response.text
}
const results = await runTools(response.toolCalls)
messages.push(response.message, ...results)
}
这段代码说明了核心机制,却没有处理超时、重试、中断、并发、循环检测和长结果。后面的文章会逐个补上。
4. 工具执行层
工具把模型的意图变成真实动作,例如读文件、查数据库、调用接口或执行命令。它也是风险最集中的地方。
生产系统通常不会让模型直接调用任意函数,而是让所有工具经过统一入口。统一入口可以集中处理参数校验、权限、审批、审计、超时和结果裁剪。
5. 状态与记忆
这里至少有三种不同的东西:当前这轮的临时状态、一段会话的历史、跨会话长期保留的信息。它们的生命周期不同,不应该混在一个大对象里。
例如“刚才工具返回了什么”属于当前执行状态;“我们上一轮讨论了什么”属于会话;“用户长期偏好 pnpm”才可能进入长期记忆。
6. 交互层
模型会持续输出文字,工具会开始、进展、完成或失败,后台任务也可能随时发来通知。交互层把这些事件变成终端、网页或桌面界面。
好的边界是:核心循环只发布事件,不关心界面用 React、Ink 还是纯文本。
7. 可观测性与评测
Agent 的错误经常不是抛出异常,而是“看起来完成了,其实漏了一步”。所以除了日志,还要记录模型步骤、工具轨迹、耗时、token、成本和最终副作用。
单元测试可以证明路径校验函数没写错,Eval 才能进一步回答:面对一组固定任务,Agent 是否真的使用了正确工具并完成目标。
边界比目录名更重要
不同项目会使用不同命名。有的把上下文构建放进 Agent 类,有的把工具和 MCP 分开,有的没有图形界面。这些都不是关键。
真正值得检查的是依赖方向:
- 核心循环是否依赖某个具体界面?
- 工具是否绕过统一的权限和审计入口?
- 会话恢复是否偷偷改变当前模型配置?
- 动态上下文是否污染了长期稳定的系统规则?
如果答案是“是”,模块虽然分了目录,边界仍然没有立住。
用 界面与观测 做个小练习
找一个你熟悉的聊天机器人或 Agent 项目,画出它处理“读取文件并总结”这条请求的路径。先只画五个节点:输入、上下文、模型、工具、输出。
然后问自己三件事:
- 工具失败时,谁决定是否重试?
- 文件内容会不会被永久写进会话?
- 如果把命令行换成网页,哪些代码可以原样复用?
如果这三个问题找不到明确归属,下一步不是马上加功能,而是先把职责边界补清楚。
用户输入 到 界面与观测 的小结
Agent Loop 是心脏,但心脏不能单独生活。入口负责启动,上下文负责准备信息,工具负责行动,状态负责延续,界面负责沟通,可观测性负责告诉我们系统到底做了什么。
下一篇我们从入口开始,看看为什么一个 Agent 应用不应该在用户只想看帮助时,就把模型、数据库和所有工具全部启动起来。
拿“读取 package.json”做一次责任审计
用户看到的只是一个问题,系统内部却发生了七次交接。入口把文本变成一次运行请求;上下文层确认工作目录和项目规则;Loop 让模型选择 read_file;工具层检查路径并读取;会话保存消息;界面展示进度;审计记录工具名称、耗时和结果摘要。任何一层都不应该用“模型知道”替代自己的输入合同。
sequenceDiagram
participant U as 用户
participant C as CLI
participant X as Context
participant L as Loop
participant T as Tool Registry
participant S as Session/Audit
U->>C: 读取 package.json 并总结
C->>X: cwd + project rules
X->>L: messages + tools
L->>T: read_file intent
T-->>L: normalized result
L-->>U: summary
L->>S: messages + evidence
审计这条路径时,不要问“用了哪些类”,而要问三个具体问题:文件读取前谁证明路径安全;工具结果以什么协议回到模型;进程中断后哪份状态还能恢复。如果答案分别落在三个模块上,这不是分散,而是边界正在发挥作用。
可选源码路径:只追一次请求
| 节点编号 | q-code 路径 | 停下来确认什么 |
|---|---|---|
| 1. 入口编排 | src/cli/main.ts |
用户请求在哪一层被转换成运行参数 |
| 2. 上下文 | src/context/prompt-builder.ts |
项目规则、工具和动态事实如何合并 |
| 3. 循环 | src/agent/loop.ts |
tool call 与 tool result 怎样进入消息账本 |
| 4. 工具关口 | src/tools/registry.ts |
参数、Hook、审计和执行如何共用入口 |
| 5. 会话 | src/session/store.ts |
哪些历史会被持久化,哪些只是本轮状态 |
当边界被塞进一个类
把模型、工具、会话和终端塞进一个 Agent 类,最后任何改动都会牵一发动全身。最先出现的症状通常不是代码太长,而是无法单独回答故障:读文件失败究竟是路径策略、模型参数、磁盘错误还是界面取消?
改造时从证据最清楚的接缝开始。例如让工具注册表只返回结构化状态,不直接打印界面文本;再让 Loop 只消费这个状态,不读取工具内部变量。每移动一条责任,都用同一个读取任务回归,避免大规模重写掩盖行为变化。
练习:删掉一个盒子
画出你项目的一次真实请求,然后尝试删掉图中的某一层。删掉工具策略后,谁负责阻止越界?删掉会话后,怎样继续上一轮?删掉审计后,怎样证明工具执行过?能明确说出损失,说明这个边界不是为了好看;说不出损失的盒子,则可能只是命名重复。
用一次误删事故检验架构图
地图画得再整齐,也要经得起事故。假设用户说“清理构建缓存”,模型却把 dist、.cache 和项目根目录下一个叫 cache-data 的业务目录都列入删除参数。工具执行成功,直到第二天业务才发现数据不见了。
这时把责任全部推给模型没有意义。模型只产生了候选动作,真实删除经过了系统允许。沿七层回放,可以看到至少五个可检查点:上下文是否解释了业务目录;工具 schema 是否要求显式列出目标;路径策略是否只按名称匹配;审批界面是否展示了解析后的绝对路径;审计是否记录了每个目标的 hash 和删除结果。
用户意图:清理“构建缓存”
模型意图:delete(["dist", ".cache", "cache-data"])
策略结果:allow
界面确认:删除 3 个目录
真实副作用:业务数据被删除
如果日志只有第一行和最后一行,团队只能猜。如果五行都在,根因通常会很具体:策略把“名称中含 cache”当成授权,界面又没有展示路径类型。修复就不该是给 Prompt 加一句“请小心”,而是让删除工具只接受构建产物清单中的目录,并在确认时展示解析后的目标与预计文件数。
这个例子说明层次不是为了分摊责任,而是为了把一次模糊错误变成几个可验证假设。模型选择可能仍然不完美,但系统不必把每个选择都变成副作用。
为每次交接写一张小票
层与层之间最好传结构化对象,而不是靠共享变量和日志文本猜状态。一张“工具调用小票”可以只包含必要字段:
interface ToolExecutionReceipt {
callId: string
requestedByStep: number
tool: string
normalizedInputHash: string
policy: 'allow' | 'confirm' | 'deny'
outcome: 'ok' | 'failed' | 'cancelled' | 'unknown'
sideEffectIds: string[]
}
policy 记录执行前的决定,outcome 记录执行后的事实,两者不能合成一个 success。用户在确认框点了取消,工具没有失败;网络断线时也可能无法判断远端动作是否发生,应该标成 unknown。这些差别会影响是否重试、是否回滚以及如何向用户解释。
会话层不必保存完整工具输出,但应该保存足以恢复推理的结果消息和小票引用;审计层保存可检索元数据;大文件正文放在受控 artifact 中。三者都“保存了一些东西”,保存目的却不同。
依赖箭头应该朝向协议
一个常见反模式是 AgentLoop 直接 import 终端组件,工具函数又直接 import 会话存储。短期少传几个参数,长期任何测试都要启动半个应用。
更稳的依赖方向是让核心逻辑只认识协议:Loop 依赖 LanguageModel、ToolExecutor 和 EventSink;终端、Web、具体模型供应商分别实现这些接口。外层负责装配,内层不反向寻找外层。
这不是要求所有东西都抽象成 interface。只有跨边界、需要替换或单测的协作者才值得显式协议。一个只在文件内部使用的字符串 helper 没必要被包装成“服务”。判断标准很直接:如果测试核心循环必须启动网络或终端,这个依赖多半穿错了方向。
第一版架构的交付标准
做一个能读文件、给出总结的小 Agent,第一版无需拥有完整平台,但至少应回答清楚:
- 输入从哪里进入,取消信号怎样传到底层。
- 模型本轮看到哪些规则、文件和工具。
- 工具路径由谁校验,拒绝结果怎样回给模型。
- assistant tool call 与 tool result 以什么顺序记账。
- 最终回答之外,哪里能找到耗时、用量和调用证据。
这五点都有明确实现和测试后,再加入写工具、记忆或多 Agent,复杂度才会沿已有边界增长。否则每加一个能力,都可能同时改入口、Loop、会话和 UI,最后看似功能很多,任何一块都不敢动。