以 DeepSeek Harness(下称 harness)为样本,拆解一条请求从进入系统到落成磁盘产物的完整链路。全文面向"知道大模型能聊天、但没写过 Agent"的读者,每个环节都给出可运行的代码骨架与踩坑清单。
一、先看一个真实场景:一句话如何变成一篇已发布的文章
假设你对 Agent 说一句:"写一篇九千字的 Agent 架构解析,配架构图,发到 CSDN。"
对聊天模型来说,这句话最多换来一段"好的,我可以帮你......"的文字。但对一个真正的 Agent 来说,这句话触发的是一串有副作用的动作:读技能说明、建目录、分块写稿、跑脚本数字数、生成架构图、驱动浏览器导入 Markdown、确认发布成功。模型本身没有手,它唯一能做的输出是 token;所有"真实发生的事",都是工具替它做的。
把这条链路摊开,一次完整的执行大约长这样:
| 序号 | 动作 | 调用面 | 产生的真实副作用 |
|---|---|---|---|
| 1 | 读任务相关技能说明 | 技能加载工具 | 上下文中多出操作规范 |
| 2 | 建输出目录、分块写正文 | 文件写入工具 | 磁盘出现 4 个 Markdown 分片 |
| 3 | 合并全文并数字数 | 命令执行工具 | 得到"汉字 9412"这类硬指标 |
| 4 | 结构断言 | 命令执行工具 | 通过/失败,失败则回到第 2 步 |
| 5 | 生成架构图并校验 | 图渲染工具 | 磁盘出现单文件 HTML |
| 6 | 导入编辑器、填标题标签 | 浏览器工具 | 草稿箱多出一篇文章 |
| 7 | 点发布、回读 URL | 浏览器工具 | 线上出现文章链接 |
注意第 3、4 步:模型"觉得"自己写够了,和脚本"证明"写够了,是两件事。第 7 步同理:脚本说"提交完成"不算数,必须回读到成功页面的 URL 才算数。这条"模型判断"与"系统验证"之间的缝,正是绝大多数 Agent 项目翻车的地方,后文会用一整章讲它。
先给出全局心智模型,后面每一章都是对这张图的放大:
#mermaid-svg-nQRKF40F3teMAwNi{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-nQRKF40F3teMAwNi .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-nQRKF40F3teMAwNi .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-nQRKF40F3teMAwNi .error-icon{fill:#552222;}#mermaid-svg-nQRKF40F3teMAwNi .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-nQRKF40F3teMAwNi .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-nQRKF40F3teMAwNi .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-nQRKF40F3teMAwNi .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-nQRKF40F3teMAwNi .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-nQRKF40F3teMAwNi .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-nQRKF40F3teMAwNi .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-nQRKF40F3teMAwNi .marker{fill:#333333;stroke:#333333;}#mermaid-svg-nQRKF40F3teMAwNi .marker.cross{stroke:#333333;}#mermaid-svg-nQRKF40F3teMAwNi svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-nQRKF40F3teMAwNi p{margin:0;}#mermaid-svg-nQRKF40F3teMAwNi .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-nQRKF40F3teMAwNi .cluster-label text{fill:#333;}#mermaid-svg-nQRKF40F3teMAwNi .cluster-label span{color:#333;}#mermaid-svg-nQRKF40F3teMAwNi .cluster-label span p{background-color:transparent;}#mermaid-svg-nQRKF40F3teMAwNi .label text,#mermaid-svg-nQRKF40F3teMAwNi span{fill:#333;color:#333;}#mermaid-svg-nQRKF40F3teMAwNi .node rect,#mermaid-svg-nQRKF40F3teMAwNi .node circle,#mermaid-svg-nQRKF40F3teMAwNi .node ellipse,#mermaid-svg-nQRKF40F3teMAwNi .node polygon,#mermaid-svg-nQRKF40F3teMAwNi .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-nQRKF40F3teMAwNi .rough-node .label text,#mermaid-svg-nQRKF40F3teMAwNi .node .label text,#mermaid-svg-nQRKF40F3teMAwNi .image-shape .label,#mermaid-svg-nQRKF40F3teMAwNi .icon-shape .label{text-anchor:middle;}#mermaid-svg-nQRKF40F3teMAwNi .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-nQRKF40F3teMAwNi .rough-node .label,#mermaid-svg-nQRKF40F3teMAwNi .node .label,#mermaid-svg-nQRKF40F3teMAwNi .image-shape .label,#mermaid-svg-nQRKF40F3teMAwNi .icon-shape .label{text-align:center;}#mermaid-svg-nQRKF40F3teMAwNi .node.clickable{cursor:pointer;}#mermaid-svg-nQRKF40F3teMAwNi .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-nQRKF40F3teMAwNi .arrowheadPath{fill:#333333;}#mermaid-svg-nQRKF40F3teMAwNi .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-nQRKF40F3teMAwNi .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-nQRKF40F3teMAwNi .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-nQRKF40F3teMAwNi .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-nQRKF40F3teMAwNi .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-nQRKF40F3teMAwNi .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-nQRKF40F3teMAwNi .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-nQRKF40F3teMAwNi .cluster text{fill:#333;}#mermaid-svg-nQRKF40F3teMAwNi .cluster span{color:#333;}#mermaid-svg-nQRKF40F3teMAwNi div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-nQRKF40F3teMAwNi .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-nQRKF40F3teMAwNi rect.text{fill:none;stroke-width:0;}#mermaid-svg-nQRKF40F3teMAwNi .icon-shape,#mermaid-svg-nQRKF40F3teMAwNi .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-nQRKF40F3teMAwNi .icon-shape p,#mermaid-svg-nQRKF40F3teMAwNi .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-nQRKF40F3teMAwNi .icon-shape .label rect,#mermaid-svg-nQRKF40F3teMAwNi .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-nQRKF40F3teMAwNi .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-nQRKF40F3teMAwNi .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-nQRKF40F3teMAwNi :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 纯文本
tool_calls
输入事件
用户消息 / 工具结果 / 定时任务
感知层:输入归一化
上下文装配
系统提示 + 规范 + 技能目录 + 历史 + 压缩摘要
推理内核 LLM
直接答复
工具调度层
注册表 / 参数校验 / 权限 / 沙箱
执行器
文件 / 命令 / 浏览器 / 网络
观测回传
结果 / 错误 / 截断
真实产物
文件 / 线上状态 / 数据
验证闭环
读回断言 / 外部判据
这张图里有三个闭环值得记住:上下文闭环 (H 回到 C,观察结果变成下一轮的输入)、产物闭环 (I 到 J,写出去的东西必须被读回来验一遍)、终止闭环(D 不产生 tool_calls 或预算耗尽时退出)。任何 Agent 框架,无论包装成什么名字,本质都在实现这三条环。
二、全景架构:六层职责与各自的失败模式
把上图按职责切成六层,每层都有明确的输入、输出与典型故障。harness 的实现里,这些层不是抽象概念,而是能对应到具体模块的东西。
| 层 | 职责 | 输入 | 输出 | 典型失败 |
|---|---|---|---|---|
| 感知层 | 把异构输入变成统一消息 | 用户文本、附件、定时触发、子任务完成通知 | 规范化消息对象 | 附件丢失、编码错乱、事件被当指令 |
| 上下文层 | 在预算内拼出"模型该看到的世界" | 消息历史、规范文件、技能目录、记忆 | 一段 token 序列 | 前缀抖动导致缓存全失效、关键约束被截断 |
| 推理层 | 决定下一步做什么 | 上下文 | 文本或工具调用意图 | 空转、重复调用、幻觉参数 |
| 调度层 | 把意图变成合法调用 | 工具名 + 参数 | 校验后的执行计划 | 参数类型错、越权、路径穿越 |
| 执行层 | 真正产生副作用 | 执行计划 | 结果、错误、资源句柄 | 超时、部分成功、编码破坏 |
| 观测层 | 把结果变成下一轮可用信息 | 原始输出 | 截断/摘要后的回注 | 巨量输出淹没上下文、错误被吞 |
harness 的工程选择很有意思:它把"感知"和"上下文"做得很重,把"推理"做得极薄。所谓 Agent 框架,代码量最大的部分往往不是循环本身(一个 while 加十行 if 就够了),而是上下文怎么拼、工具怎么管、权限怎么卡、产物怎么验。一个刚接触的人容易把精力全花在 prompt 上,结果做出来的东西"聊得很好,什么都干不成"。
下面逐层展开,每层给一段可抄的代码。
三、感知层:Agent 收到的从来不是"一句话"
3.1 三类输入,一种表示
小白最容易建立的错误直觉是"用户输入就是字符串"。真实系统里,进入推理内核的东西至少有三种来源:
第一类是会话消息 ,包括用户文本、图片、文件引用。第二类是工具结果 ,也就是上一轮工具执行完回填的内容,它可能是一段报错、一份文件内容、一个网页快照。第三类是环境事件:定时任务到点触发、后台任务跑完、团队成员发来消息、审批通过或拒绝。这三类在语义上完全平等------对模型来说,它们都是"上一轮之后发生的新事实"。
但它们的可信度并不平等。用户文本可能包含"忽略你之前的指令"这类注入攻击;工具结果(尤其是网页内容)同样可能是恶意的。harness 的做法是在系统提示里固定一条边界:外部内容一律视为数据,不作为指令执行。这一条必须在感知层就写死,靠推理层"自觉遵守"是不可靠的。
3.2 上下文装配:顺序比内容更重要
真正决定 Agent 行为的,不是单条消息,而是装配后的完整序列。harness 的装配顺序大致是:
- 系统提示(身份、安全边界、输出纪律)
- 工作区规范文件(团队约定、铁律、成本纪律)
- 技能目录摘要(有哪些技能、各自何时用,只给描述不给全文)
- 运行时快照(当前目录、文件权限、时间、模型、界面地址)
- 历史消息(含已折叠的压缩摘要)
- 本轮新输入
这个顺序不是审美问题,而是缓存经济学。主流推理服务按"稳定前缀命中"计费,前缀只要有一个字节变了,从变化点往后全部按未命中重算。所以稳定的东西必须放前面,易变的东西放后面。如果每次请求都在系统提示里塞一个当前时间戳,恭喜你,缓存命中率直接归零,账单翻十倍。
技能目录只给摘要、不给全文,也是同一个道理:目录体积小、变化少,全文加载会把前缀撑大且频繁变动。真正要用某个技能时,再单独调用加载工具把全文读进来------这是"按需取回"思想在上下文层的第一次应用,后面压缩、记忆都会重复它。
3.3 代码案例:一个可用的装配函数
python
from dataclasses import dataclass, field
@dataclass
class Message:
role: str # system / user / assistant / tool
content: str
meta: dict = field(default_factory=dict)
def assemble(system_prompt, workspace_rules, skill_catalog,
runtime_snapshot, history, new_input, budget_tokens=96000):
parts = [
Message("system", system_prompt),
Message("system", workspace_rules),
Message("system", skill_catalog),
Message("system", runtime_snapshot),
]
# 历史从尾部回填,超预算时优先保留最近轮次
kept, used = [], estimate(parts) + estimate(new_input)
for msg in reversed(history):
cost = estimate([msg])
if used + cost > budget_tokens:
break
kept.append(msg)
used += cost
kept.reverse()
return parts + kept + [new_input]
def estimate(messages):
# 粗估:中文约 1.5 字符/token,英文约 4 字符/token
chars = sum(len(m.content) for m in messages)
return int(chars / 2)
这段代码有两个细节值得强调。第一,历史是从尾部往前保留的,因为最近的上下文最相关;被截掉的旧历史不是丢弃,而是应该先经过压缩变成摘要再放回,否则 Agent 会"忘了自己刚才干了什么"。第二,预算是硬约束,不是建议值------超过窗口的请求会直接报错,而报错发生在任务最关键的收尾阶段,代价最大。
3.4 感知层的三个常见坑
第一坑:把大文件整篇塞进上下文。一份 5 万字的规范塞进去,看起来"信息充分",实际上挤掉了历史、抬高了成本、还稀释了注意力。正确做法是先检索定位到相关章节,只读命中窗口。
第二坑:编码。在 Windows 上,用错误的编码读写含中文的源码,会把文件变成乱码,而工具返回"写入成功"。这类故障不会在写入时报错,只会在十几步之后编译失败时暴露,排查成本极高。凡涉及中文文件,读写路径必须显式指定 UTF-8。
第三坑:事件被当成指令。定时任务、网页抓取、别人的代码注释里都可能出现命令式语句。感知层要做的是给这类内容打上"数据"标签,而不是原样拼进上下文指望模型分辨。
3.5 预算到底该给多少
一个新项目最常见的两个极端:全塞进去,或者只留最近几条。前者在第一个长任务上就撞窗口,后者让 Agent 患上失忆症------它不记得十分钟前自己删过什么文件,于是又删一遍。
可以按三段来分配预算。头部是稳定区,系统提示加规范加技能目录,一般占总预算的百分之十到十五,且必须字节级稳定。中部是历史区,占百分之六十左右,超出部分走压缩。尾部是当前动作的输入与工具结果,占百分之二十五左右,单条工具结果建议不超过八千字符,超了就截断并标注"已截断,剩余部分请用检索工具按需读取"。
这个比例不是教条,但它能解决大部分"为什么它又忘了"的问题:给尾部留足空间,当前这一步的观察才看得清;给历史留足空间,任务链才连得上;头部别膨胀,钱才花得值。
3.6 一个容易被忽略的事实:上下文不是记忆
上下文是"这一轮模型能看到的窗口",记忆是"跨窗口可检索的存储"。很多人把两者混为一谈,于是出现两种奇怪的设计:把整份项目文档当记忆每次全量注入,或者指望上下文里提过一次的事情永远不忘。
正确的分工是:上下文只放"当前决策必需",记忆放"以后可能有用"。前者追求相关性与体积比,后者追求可检索性。判断标准很简单:这条信息如果不给模型,它这一步就会做错吗?会,放上下文;不会,放记忆等检索。
本章小结
感知层的工作可以浓缩成一句话:把世界上发生的各种事,翻译成模型能理解、且不超出预算的一小段文字。翻译质量决定后面所有环节的上限------喂进去的是噪声,出来的必然是幻觉。
四、推理内核:Agent Loop 的真实形状
4.1 一轮 step 到底做了什么
上下文拼好之后,交给模型。模型返回两种东西之一:纯文本,或者一组工具调用(tool_calls)。纯文本意味着它认为"信息够了,可以回答";工具调用意味着它要"动手"。
一轮 step 的完整动作是:调用模型 → 解析响应 → 如果是工具调用,逐个执行 → 把每个执行结果作为一条 tool 消息追加进历史 → 回到循环开头。就这么朴素。所谓 ReAct(Reason + Act)范式,落到代码上就是这一句话。
harness 里这条循环叫 agent loop,它承担四件事:状态推进(把历史和工具结果串起来)、预算控制(步数、token、时间)、压缩触发(水位超过阈值时折叠历史)、终止判定(何时停下来交付)。注意后三件事都不是模型决定的,是内核决定的------这一步很关键,把"何时停"交给模型,就是放弃了成本与安全控制。
4.2 终止条件必须由系统掌握
一个健壮的循环至少有五个出口:
- 模型返回纯文本,没有工具调用(正常结束)
- 达到最大步数(防止死循环)
- token 预算耗尽(防止成本失控)
- 用户中断(前端发来取消信号)
- 策略拒绝(权限不足、危险操作需要审批)
缺任何一个,都会出现对应的经典事故:少了第 1 个,循环永远不出口;少了第 2 个,模型在两个工具之间反复横跳直到账单爆炸;少了第 3 个,长任务在深夜把你的额度烧光;少了第 4 个,用户点了停止但后台还在跑;少了第 5 个,一条 prompt 注入就能删掉整个目录。
4.3 时序:一次工具调用在系统里怎么流转
执行器 工具调度层 模型 Agent Loop 用户 执行器 工具调度层 模型 Agent Loop 用户 #mermaid-svg-3QubMQCvuIfisgS4{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-3QubMQCvuIfisgS4 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-3QubMQCvuIfisgS4 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-3QubMQCvuIfisgS4 .error-icon{fill:#552222;}#mermaid-svg-3QubMQCvuIfisgS4 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-3QubMQCvuIfisgS4 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-3QubMQCvuIfisgS4 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-3QubMQCvuIfisgS4 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-3QubMQCvuIfisgS4 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-3QubMQCvuIfisgS4 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-3QubMQCvuIfisgS4 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-3QubMQCvuIfisgS4 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-3QubMQCvuIfisgS4 .marker.cross{stroke:#333333;}#mermaid-svg-3QubMQCvuIfisgS4 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-3QubMQCvuIfisgS4 p{margin:0;}#mermaid-svg-3QubMQCvuIfisgS4 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-3QubMQCvuIfisgS4 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-3QubMQCvuIfisgS4 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-3QubMQCvuIfisgS4 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-3QubMQCvuIfisgS4 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-3QubMQCvuIfisgS4 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-3QubMQCvuIfisgS4 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-3QubMQCvuIfisgS4 .sequenceNumber{fill:white;}#mermaid-svg-3QubMQCvuIfisgS4 #sequencenumber{fill:#333;}#mermaid-svg-3QubMQCvuIfisgS4 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-3QubMQCvuIfisgS4 .messageText{fill:#333;stroke:none;}#mermaid-svg-3QubMQCvuIfisgS4 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-3QubMQCvuIfisgS4 .labelText,#mermaid-svg-3QubMQCvuIfisgS4 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-3QubMQCvuIfisgS4 .loopText,#mermaid-svg-3QubMQCvuIfisgS4 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-3QubMQCvuIfisgS4 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-3QubMQCvuIfisgS4 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-3QubMQCvuIfisgS4 .noteText,#mermaid-svg-3QubMQCvuIfisgS4 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-3QubMQCvuIfisgS4 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-3QubMQCvuIfisgS4 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-3QubMQCvuIfisgS4 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-3QubMQCvuIfisgS4 .actorPopupMenu{position:absolute;}#mermaid-svg-3QubMQCvuIfisgS4 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-3QubMQCvuIfisgS4 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-3QubMQCvuIfisgS4 .actor-man circle,#mermaid-svg-3QubMQCvuIfisgS4 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-3QubMQCvuIfisgS4 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 输入任务装配后的上下文tool_calls: write(file, content)校验工具名与参数权限检查 / 路径解析执行写入ok, 3124 字节tool 结果消息上下文 + 上一轮结果纯文本答复交付
这张图里最容易被忽略的是"校验"那一格。模型给出的参数是"意图",不是"合法调用":路径可能是相对路径、可能带 .. 穿越、类型可能是字符串而 schema 要求整数、文件可能已经存在不该覆盖。调度层要做的就是把意图变成合法调用,并且在不合法时返回结构化错误,让模型能自己修正。
4.4 代码骨架:一个 60 行的循环
python
class AgentLoop:
def __init__(self, llm, tools, max_steps=40, token_budget=200_000):
self.llm, self.tools = llm, tools
self.max_steps, self.token_budget = max_steps, token_budget
def run(self, messages):
used = 0
for step in range(self.max_steps):
if used > self.token_budget:
return {"status": "budget_exhausted", "messages": messages}
resp = self.llm.chat(messages, tools=self.tools.specs())
used += resp.usage.total_tokens
messages.append(resp.as_message())
if not resp.tool_calls:
return {"status": "done", "messages": messages}
for call in resp.tool_calls:
result = self.tools.execute(call.name, call.arguments, call.id)
messages.append({
"role": "tool",
"tool_call_id": call.id,
"content": result.to_text(max_chars=8000),
})
return {"status": "max_steps", "messages": messages}
注意 to_text(max_chars=8000):工具结果必须限长。一次 cat 一个二进制文件或者抓一个巨型网页,就能把上下文瞬间打满,后面所有推理都在噪声里进行。限长不是可选项,是必需品。
4.5 "模型说完成了"不算完成
这是全篇最重要的一句话。模型判断任务完成,依据的是它自己写下的文字,不是外部世界的状态。它可能写完文件就说"已保存",但文件根本没写成功;它可能说"已发布",但浏览器停在了登录页。
所以循环的出口不该是"模型说完了",而应该是"验证通过"。实践中的做法有两种:轻量的,让模型在结束前必须调用一次读回工具(比如读文件头部、查发布结果页 URL);重量的,把验证写成确定性脚本,由系统在模型之外跑一遍。前者依赖模型自觉,后者才是工程。第五章之后讲产物时会展开。
五、工具调用:从 JSON 参数到真实副作用
5.1 工具的三件套
一个工具能被模型正确调用,靠三样东西:名字、描述、参数 schema。
名字要短且唯一,write、read、grep 这种动词比 file_operation_write_v2 好得多------名字进上下文,每个字都花钱。描述是这个工具的"使用说明书",也是它最重要的 prompt:什么场景用、什么场景不用、参数含义、失败时怎么办。经验法则是描述里不要写"用于操作文件"这种废话,要写"覆盖写入整个文件;已存在且未读过时会被拒绝;大文件请分块"。参数 schema 用 JSON Schema 表达,类型、必填项、枚举值写清楚,模型照着填的准确率会显著提升。
harness 里有个现实细节:工具可能被插件替换。同一个"读文件"动作,官方工具是一个实现,运维插件接管后是另一个实现,行为与报错文案都可能不同。所以工具描述不是摆设,它是运行时契约,读文件前先确认当前生效的是哪个实现,比背文档可靠。
5.2 调度层的四道闸
模型给出 tool_name + arguments 之后,调度层依次过四道闸:
第一道是存在性。工具名必须命中注册表,否则返回"未知工具,可用工具有......",顺便把候选列表回注,模型下一轮往往就能改对。
第二道是参数校验。类型、必填、取值范围、字符串长度。校验失败返回具体字段与期望类型,不要返回"参数错误"这种无用信息。
第三道是权限。读写文件、执行命令、访问网络、驱动浏览器,是四类完全不同的风险。文件写要看沙箱策略(只读、工作区可写、完全访问),命令执行要看是否允许逃逸沙箱,网络访问要看目标域名,浏览器操作要看是否涉及登录态与不可逆动作(发布、下单、删除)。
第四道是路径与语义解析 。相对路径按哪个目录解析、目标是否在允许范围内、文件是否已存在、是否覆盖。这一道最容易出安全事故:../../etc/passwd 这种输入,靠字符串黑名单是拦不住的,必须做规范化之后再做前缀比较。
5.3 权限设计:审批点该放在哪
把审批放在"模型决定调用时"还是"执行器真正动手前",效果完全不同。放在前者,审批信息里只有意图,用户很难判断风险;放在后者,可以展示"将要写入 /abs/path/foo.md,覆盖已有 3.2KB 文件",判断依据充分得多。
harness 的做法是分层:读类操作默认放行,写类操作按沙箱策略判定,危险操作(删库、发布、转账)强制审批,而审批被禁用时直接拒绝而不是默默执行。这个"默认拒绝"的取向值得抄------安全设计的默认值应该是最小权限,而不是最方便。
5.4 幂等、超时与后台任务
工具执行有三个工程问题绕不开。
幂等:同一个调用重放两次,结果应该一致。写文件天然幂等(覆盖即可),但"追加""发布""发消息"不幂等。重试逻辑必须知道哪些工具可以盲重试,哪些必须先查状态。
超时:命令执行、网络请求、浏览器操作都要有超时。超时之后不能假装成功,要把"未知状态"如实回注给模型,让它自己决定是查状态还是重试。
后台化:长任务(编译、下载、批量转换)应该扔进后台作业,立即返回作业 ID,模型后续用"查作业"工具轮询。否则一次 10 分钟的命令会把整个循环卡死,用户在界面上什么也看不到。
5.5 代码案例:带闸门的执行器
python
class ToolRegistry:
def __init__(self):
self._tools = {}
def register(self, name, description, schema, handler, danger="low"):
self._tools[name] = dict(description=description, schema=schema,
handler=handler, danger=danger)
def specs(self):
return [{"name": n, "description": t["description"],
"parameters": t["schema"]} for n, t in self._tools.items()]
def execute(self, name, args, call_id):
tool = self._tools.get(name)
if tool is None:
return Result.error(f"未知工具 {name},可用:{list(self._tools)}")
err = validate(args, tool["schema"])
if err:
return Result.error(f"参数不合法:{err}")
verdict = policy.check(name, args, tool["danger"])
if verdict == "deny":
return Result.error("权限拒绝:当前沙箱不允许该操作")
if verdict == "ask" and not approvals.available():
return Result.error("该操作需要审批,但本会话审批已禁用")
try:
return Result.ok(tool["handler"](**args))
except Exception as e:
return Result.error(f"{type(e).__name__}: {e}")
这段代码刻意把"异常"变成"结构化错误"返回,而不是向上抛。原因很简单:抛异常会终结整个任务,而返回错误能让模型看到原因、换个策略重试。Agent 的鲁棒性,很大程度上来自"错误是信息而不是终点"这个设计。
5.6 工具粒度的取舍
工具做得太细(open_file、read_line、seek),模型要花十几步才能读完一个文件;做得太粗(do_anything),权限无法细分、错误无法定位、复用性归零。经验值是"一个工具对应一个用户能理解的动作",并且把常见组合封装成批量接口,比如"批量填表"而不是"逐个字段赋值"。批量接口还有个隐性收益:它减少了往返次数,而每次往返都要重发一遍上下文,成本是大头。
5.7 工具结果的整形:回注什么、不回注什么
工具执行完,结果要回到上下文。这里有个反直觉的结论:回注得越全,Agent 越笨。原因不是模型读不懂长文本,而是长文本里的无关信息会稀释注意力,让它在下一步做出无关动作。
整形有三条规则。第一,成功时只回注"证据",不回注全部原始数据:写文件回注路径与字节数,执行命令回注退出码与前后各若干行输出,抓网页回注标题、正文摘要与链接列表。第二,失败时回注"原因加可操作建议",比如"文件已存在且未读过,请先读再写",而不是一整段堆栈------堆栈对模型几乎没有信息增量,对上下文却是纯负担。第三,超长结果必须带截断标记与取回方式,告诉模型"后面还有,用检索工具取",这样它不会误以为看完了。
还有一个常被忽略的点:工具的返回值格式要稳定。同一工具这次返回 JSON、下次返回自然语言,模型就得每轮重新摸索格式,浪费 token 还容易解析错。格式稳定本身就是一种提示。
5.8 工具失败之后:错误是同一条路还是岔路
错误回注给模型后,它有三种反应,分别对应三种系统设计。
第一种是"换个参数重试",对应参数校验类错误。这要求错误信息精确到字段,模型才能修正。
第二种是"换条路走",对应能力不足类错误。比如目标网站需要登录,浏览器工具报未登录,模型应该改走"请用户登录后继续"而不是反复重试。这要求错误信息里带"建议下一步",否则模型会陷入重试循环。
第三种是"停下来问人",对应权限或歧义类错误。这类错误不该由模型自己猜,系统要提供"升级到人"的通道,比如审批请求或澄清问题。
区分这三种的关键,是错误码而不是错误文案。文案给模型看,错误码给程序看:程序据此决定是否熔断(同一个错误连续出现三次就停),模型据此决定换策略。没有错误码的系统,最终都会退化成"模型瞎试,用户干等"。
本章小结
推理内核和工具调度是 Agent 的"发动机与传动轴"。发动机要薄------循环本身简单、可测、预算硬约束;传动轴要厚------校验、权限、整形、错误分类全在这里。很多人反过来做:循环里塞满业务判断,工具层却裸奔,结果既难调试也不安全。
六、产物层:模型输出如何变成磁盘上的东西
6.1 产物的四种形态
Agent 的"成果"不是一段文字,而是外部世界状态的变化。按可见性分四类:
文件产物:Markdown 稿、代码、脚本、图片、报表。它落在文件系统里,可被再次读取、被版本控制、被别人使用。
外部系统状态:文章已发布、分支已推送、工单已创建、表单已提交。这类产物的判据不在本地,必须去远端回读。
结构化数据:解析结果、抽取的字段、聚合的指标。判断标准是 schema 校验与数值断言。
可视化:架构图、流程图、报表截图。它需要渲染器,也需要人眼复核。
这四类的验证方式完全不同,但有一个共同点:都不能以模型的自述为准。
6.2 写入路径的三段式
从模型到磁盘,中间有三段,每段都可能断:
第一段,模型生成内容。它可能生成到一半被截断(达到 max_tokens),可能在 JSON 字符串里转义错乱,可能把中文写成 \uXXXX 转义序列。这些都会静默产生"看起来完整、实际坏了"的文件。
第二段,工具执行写入。这里涉及编码(UTF-8 vs GBK)、换行(LF vs CRLF)、路径(相对 vs 绝对)、覆盖策略(新建 vs 覆盖)。Windows 上最经典的事故是用错误的编码读写含中文的源码,文件变成乱码而工具返回成功------故障点被推迟到十几步后的编译错误,排查时已经很难定位到最初那次写入。
第三段,文件系统落盘。正常情况没有悬念,但要注意"写入成功"只代表字节进了磁盘,不代表内容正确。
所以工程上要养成一个习惯:写完立刻读回一小段。读回头部 200 字符,就能发现编码错乱、转义错误、内容被截断。这个动作的成本是一次工具调用,收益是避免一次灾难性返工。
6.3 验证闭环的三层
验证不是"看一眼",而是分层断言。
第一层是存在性:文件在不在、大小是否为 0、修改时间是不是刚才。这一层最便宜,能拦住"模型以为写了其实没写"。
第二层是内容断言:字数是否落在区间、章节标题是否齐全、代码围栏是否成对、表格行数是否匹配、占位符是否残留。这层要用确定性脚本跑,不能让模型自己数------模型数数是出了名的不可靠。
第三层是外部判据:发布成功的标志是 URL 命中成功页,或者页面出现成功文案;推送成功的标志是远端分支的 commit hash 一致。这层必须查询外部系统,本地状态说明不了任何问题。
harness 这类框架把第三层做得很硬:脚本自报成功不算,必须回读到成功页面的地址。这不是不信任脚本,而是承认"提交动作完成"和"服务端已接受"之间存在时间差与失败可能。
6.4 代码案例:把字数收口做成断言
python
import re, sys
path = sys.argv[1]
text = open(path, encoding="utf-8").read()
hanzi = len(re.findall(r"[\u4e00-\u9fff]", text))
fences = text.count("`" * 3)
h2 = len(re.findall(r"^## ", text, flags=re.M))
table_rows = sum(1 for ln in text.splitlines()
if ln.strip().startswith("|") and not ln.strip().startswith("|---"))
checks = {
"汉字数在 9000-10000": 9000 <= hanzi <= 10000,
"代码围栏成对": fences % 2 == 0,
"二级标题不少于 8 个": h2 >= 8,
"正文有表格": table_rows > 0,
"无残留占位符": "TODO" not in text and "待补充" not in text,
}
for k, ok in checks.items():
print(("PASS " if ok else "FAIL ") + k)
print(f"hanzi={hanzi} h2={h2} tables={table_rows} chars={len(text)}")
sys.exit(0 if all(checks.values()) else 1)
这个脚本的价值在于它把"够不够"变成了布尔值。没有它,你会在"应该差不多够了"的自我感觉里反复循环;有了它,每一轮修改都有明确的反馈信号。Agent 的自主性越强,这种确定性检查点就越是必需品。
6.5 一个真实的反例
某次任务里,模型连续写了五个分片文件,感觉内容已经足够,于是直接合并交付。合并脚本只并了前四个分片------因为第五个分片是后来补写的,合并范围的数字没改。结果四个分片顺利合并,四套字数指标正常打印,看起来一切正常,而最后一章整章消失。
这个故障的教训有三条:合并脚本的范围要由文件列表推导,不要硬编码;合并后要核对章节编号的连续性;任何"总量统计通过"都不能替代"结构一致性检查"。人的直觉会盯着总量,而缺陷总藏在结构里。
七、六个关键设计取舍
做 Agent 框架,绝大多数争论最终都能归到这六组取舍上。没有绝对正确的答案,只有与场景匹配的选择。
| 取舍 | 选 A 的理由 | 选 B 的理由 | 建议 |
|---|---|---|---|
| 全量历史 vs 压缩摘要 | 信息无损,模型不会忘 | 成本可控,窗口不爆 | 短任务全量,长任务在 70% 水位折叠 |
| 串行工具 vs 并行工具 | 状态简单,易调试 | 墙钟时间短 | 无依赖才并行,写操作一律串行 |
| 工具粒度细 vs 粗 | 权限可细分 | 往返少、成本低 | 按用户可理解的动作切,组合动作做批量接口 |
| 审批前置 vs 后置 | 拦截早 | 信息全,误报少 | 放在执行器动手前,展示具体影响 |
| 盲目重试 vs 先查状态 | 代码简单 | 不重复副作用 | 只对幂等工具盲重试,其余先查 |
| 自检 vs 外部判据 | 快 | 可信 | 全部用外部判据,自检只作辅助 |
第三条值得多说一句。很多人喜欢做"万能工具",一个 execute 什么都能干。这在原型阶段很爽,一到生产就寸步难行:无法按操作类型授权、无法采集细粒度指标、无法给用户展示"它到底要干什么"。工具边界就是权限边界,也是可观测性边界。
第四条同样关键。审批如果发生在模型"决定"调用的时候,用户看到的是"write(file=...)",没法判断风险;如果发生在执行器解析完路径之后,用户看到的是"覆盖 /workspace/report.md(已有 12KB)",判断质量完全不同。审批的本质是给人提供决策信息,信息越晚越全。
八、技术链关系图:事件、缓存、记忆、权限如何咬合
单看每一层都简单,凑在一起才出现真正的复杂度。下面这张图标出四条相互影响的链:
#mermaid-svg-ujwAjZVb2SVXBeda{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ujwAjZVb2SVXBeda .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ujwAjZVb2SVXBeda .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ujwAjZVb2SVXBeda .error-icon{fill:#552222;}#mermaid-svg-ujwAjZVb2SVXBeda .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ujwAjZVb2SVXBeda .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ujwAjZVb2SVXBeda .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ujwAjZVb2SVXBeda .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ujwAjZVb2SVXBeda .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ujwAjZVb2SVXBeda .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ujwAjZVb2SVXBeda .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ujwAjZVb2SVXBeda .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ujwAjZVb2SVXBeda .marker.cross{stroke:#333333;}#mermaid-svg-ujwAjZVb2SVXBeda svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ujwAjZVb2SVXBeda p{margin:0;}#mermaid-svg-ujwAjZVb2SVXBeda .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-ujwAjZVb2SVXBeda .cluster-label text{fill:#333;}#mermaid-svg-ujwAjZVb2SVXBeda .cluster-label span{color:#333;}#mermaid-svg-ujwAjZVb2SVXBeda .cluster-label span p{background-color:transparent;}#mermaid-svg-ujwAjZVb2SVXBeda .label text,#mermaid-svg-ujwAjZVb2SVXBeda span{fill:#333;color:#333;}#mermaid-svg-ujwAjZVb2SVXBeda .node rect,#mermaid-svg-ujwAjZVb2SVXBeda .node circle,#mermaid-svg-ujwAjZVb2SVXBeda .node ellipse,#mermaid-svg-ujwAjZVb2SVXBeda .node polygon,#mermaid-svg-ujwAjZVb2SVXBeda .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ujwAjZVb2SVXBeda .rough-node .label text,#mermaid-svg-ujwAjZVb2SVXBeda .node .label text,#mermaid-svg-ujwAjZVb2SVXBeda .image-shape .label,#mermaid-svg-ujwAjZVb2SVXBeda .icon-shape .label{text-anchor:middle;}#mermaid-svg-ujwAjZVb2SVXBeda .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ujwAjZVb2SVXBeda .rough-node .label,#mermaid-svg-ujwAjZVb2SVXBeda .node .label,#mermaid-svg-ujwAjZVb2SVXBeda .image-shape .label,#mermaid-svg-ujwAjZVb2SVXBeda .icon-shape .label{text-align:center;}#mermaid-svg-ujwAjZVb2SVXBeda .node.clickable{cursor:pointer;}#mermaid-svg-ujwAjZVb2SVXBeda .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ujwAjZVb2SVXBeda .arrowheadPath{fill:#333333;}#mermaid-svg-ujwAjZVb2SVXBeda .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ujwAjZVb2SVXBeda .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ujwAjZVb2SVXBeda .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ujwAjZVb2SVXBeda .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ujwAjZVb2SVXBeda .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ujwAjZVb2SVXBeda .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ujwAjZVb2SVXBeda .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ujwAjZVb2SVXBeda .cluster text{fill:#333;}#mermaid-svg-ujwAjZVb2SVXBeda .cluster span{color:#333;}#mermaid-svg-ujwAjZVb2SVXBeda div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ujwAjZVb2SVXBeda .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ujwAjZVb2SVXBeda rect.text{fill:none;stroke-width:0;}#mermaid-svg-ujwAjZVb2SVXBeda .icon-shape,#mermaid-svg-ujwAjZVb2SVXBeda .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ujwAjZVb2SVXBeda .icon-shape p,#mermaid-svg-ujwAjZVb2SVXBeda .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ujwAjZVb2SVXBeda .icon-shape .label rect,#mermaid-svg-ujwAjZVb2SVXBeda .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ujwAjZVb2SVXBeda .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ujwAjZVb2SVXBeda .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ujwAjZVb2SVXBeda :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 事件总线
消息/定时/子任务
上下文装配
稳定前缀
系统提示+规范
历史消息
压缩/折叠
长期记忆
跨会话检索
推理内核
工具调度
权限/沙箱
执行器
产物
验证闭环
可观测/成本账本
第一条链:缓存决定成本结构。 稳定前缀(系统提示、规范文件、技能目录)必须字节级不变。任何"每次请求都塞时间戳""每次动态调整模型档位"的动作,都会让前缀失效,成本按未命中重算。实测中,命中率从 99% 掉到 90%,账单可能翻三到五倍------因为未命中单价通常是命中的数十倍。
第二条链:压缩决定窗口能不能撑住长任务。 上下文到 70% 左右开始折叠历史,折叠产物是摘要,放回历史区。折叠有代价:它要改写历史,导致前缀失效一次,所以要攒够再折,别频繁折。判断标准是"重付一次前缀的成本 < 省下的未命中 token 乘以剩余轮数"。
第三条链:记忆决定跨会话能力。 会话内的历史靠压缩,会话之间的知识靠记忆库。记忆的写入必须克制:只存用户明确表达的偏好、被验证过的决策,不要把过程日志、临时进度、模型自己的猜测写进去,否则检索出来的全是噪声,反而污染上下文。
第四条链:权限决定能走多远。 沙箱策略决定了执行器能碰什么。只读模式下的 Agent 可以做分析与建议,工作区可写才能改代码,完全访问才能做系统级操作。这条链和验证闭环强耦合:权限越大,验证越要硬,因为一次错误写入的破坏面更大。
四条链共同的约束是成本。Agent 的成本结构里,输入侧(未命中 + 命中)和输出侧都要压,但压法不同:输入侧靠稳定前缀与压缩,输出侧靠控制啰嗦与减少无效往返。这也是为什么"少而准"的工具调用设计,比"多而全"的工具集更省钱。
8.1 四条链的冲突点
分开看四条链都合理,合在一起就出现三组冲突,这是设计 Agent 框架时必须提前想清楚的。
第一组:缓存想要稳定,压缩必然改写历史。每折一次历史,前缀就变一次,缓存全部失效。解法是"攒够再折":用剩余轮数与重付成本算一笔账,折得起了再折,而不是一到阈值就折。
第二组:记忆想要更多召回,上下文想要更少噪声。解法是分层召回:先按语义粗召回,再用重排与时间衰减精排,只把最相关的前几条注入,并且注入时标注来源,让模型知道该不该信。
第三组:权限想要更严,能力想要更强。严格的沙箱会拦掉一批本该成功的任务。解法是分级授权加审批:默认最小权限,遇到需要更大权限的操作时向上申请,把决定权交回人手里,而不是放宽默认值。
这三组冲突没有一次性解法,它们会随着任务类型反复出现。成熟的框架不是消除了它们,而是把它们变成了可配置的旋钮。
本章小结
取舍与关系图的真正用途,是在做设计评审时快速定位风险。当有人提议"给每个请求加一个当前时间戳",你要能立刻指出它会击穿第一条链;当有人提议"把整个知识库都注入上下文",你要能指出它违反第二组冲突。能把这些说出来,比会画架构图有用得多。
九、给小白的最小可运行实现
把前面所有章节压成一个能跑的文件,大约一百行。它的价值不是替代生产框架,而是让你亲手验证每条环都真的存在。
python
import json, os, subprocess
SYSTEM = """你是一个执行型 Agent。
规则:1) 需要真实操作时必须调用工具,不要用文字代替;
2) 每次只调一个工具,拿到结果再决定下一步;
3) 结束前必须读回产物确认,不得凭记忆宣称完成。"""
TOOLS = [
{"name": "write_file",
"description": "覆盖写入文本文件,路径相对工作区",
"parameters": {"type": "object", "required": ["path", "content"],
"properties": {"path": {"type": "string"},
"content": {"type": "string"}}}},
{"name": "read_file",
"description": "读取文件前 2000 字符用于核对",
"parameters": {"type": "object", "required": ["path"],
"properties": {"path": {"type": "string"}}}},
{"name": "run_cmd",
"description": "在工作区执行命令,返回 stdout 与退出码",
"parameters": {"type": "object", "required": ["cmd"],
"properties": {"cmd": {"type": "string"}}}},
]
ROOT = os.path.abspath("./workspace")
def safe_path(p):
full = os.path.abspath(os.path.join(ROOT, p))
if not full.startswith(ROOT + os.sep):
raise PermissionError("路径越界")
return full
def execute(name, args):
try:
if name == "write_file":
full = safe_path(args["path"])
os.makedirs(os.path.dirname(full), exist_ok=True)
open(full, "w", encoding="utf-8", newline="").write(args["content"])
return f"ok: 写入 {len(args['content'])} 字符"
if name == "read_file":
return open(safe_path(args["path"]), encoding="utf-8").read()[:2000]
if name == "run_cmd":
r = subprocess.run(args["cmd"], shell=True, cwd=ROOT,
capture_output=True, text=True, timeout=120)
return f"exit={r.returncode}\n{r.stdout[:4000]}\n{r.stderr[:2000]}"
return f"error: 未知工具 {name}"
except Exception as e:
return f"error: {type(e).__name__}: {e}"
def run(client, task, max_steps=15):
msgs = [{"role": "system", "content": SYSTEM},
{"role": "user", "content": task}]
for _ in range(max_steps):
resp = client.chat(messages=msgs, tools=TOOLS)
msg = resp.choices[0].message
msgs.append({"role": "assistant", "content": msg.content,
"tool_calls": getattr(msg, "tool_calls", None)})
if not getattr(msg, "tool_calls", None):
return msg.content
for call in msg.tool_calls:
args = json.loads(call.function.arguments or "{}")
out = execute(call.function.name, args)
msgs.append({"role": "tool", "tool_call_id": call.id,
"content": out})
return "达到最大步数,任务未收敛"
跑起来之后,你会亲眼看到三件事:模型会在没有工具可用时试图"编"结果;写文件之后不读回,它真的会记错自己写了什么;路径越界被拦下时,它能根据错误信息自己修正。这三件事,就是全文所有理论的实验版本。
9.1 建议你按顺序做的三次实验
第一次,只给它读文件的能力。让它回答"这个项目用了什么框架"。你会看到它必须先读、再答,而不是凭包名猜------这一步建立"工具先于结论"的习惯。
第二次,加上写文件与执行命令。让它把一份数据清洗后写成 CSV,并要求它用命令验证行数。你会看到验证那一步怎么救回一次失败的写入:如果它没校验,会自信地报告"已生成一千行",而实际只有三百行。
第三次,加一个需要外部确认的动作,比如提交表单或推送分支。让它做完后回读外部状态。你会看到"动作完成"与"状态生效"之间的距离,也会理解为什么验证闭环必须查外部判据。
三次实验做完,你就拥有了一个最小但完整的 Agent 心智模型。之后无论用哪个框架,看它的文档时都能对号入座:这一层对应感知,那一层对应调度,验证在哪里,成本怎么算。这种"看穿"的感觉,比背十个 API 有用。
十、十个高频坑与排查表
| 现象 | 根因 | 处理 |
|---|---|---|
| 模型说"已完成",但文件不存在 | 把自述当结果 | 强制读回 + 存在性断言 |
| 文件内容乱码 | 非 UTF-8 读写 | 显式指定编码并读回头部 |
| 任务中途开始胡言乱语 | 上下文被打满 | 限制工具输出长度、及时压缩 |
| 账单突然翻倍 | 前缀失效导致缓存未命中 | 检查系统提示是否含时间戳等易变字段 |
| 同一工具被反复调用 | 结果没被理解或没有终止条件 | 限步数、回注结构化错误 |
| 命令永远不返回 | 缺少超时 | 所有执行入口设超时,长任务转后台 |
| 越权删文件 | 路径未规范化 | 规范化后做目录前缀比较 |
| 发布"成功"却查不到 | 只信本地返回值 | 回读远端成功页 URL |
| 多分片合并丢章 | 合并范围硬编码 | 由文件列表推导 + 校验章节连续性 |
| 输出越来越长 | 缺少输出纪律 | 在系统提示里约定简洁口径并给出示例 |
十一、结语:Agent 的能力边界由什么决定
回到开头那句话。模型只会输出 token,它能做出文章、图、发布动作,全靠三样东西托底:足够好的上下文 让它知道该干什么,足够安全的工具面 让它能真的干,足够硬的验证闭环让它干对了才算数。
三者缺一,结果都很具体:缺上下文,它会一本正经地做错方向;缺工具,它只能写一段"我建议你......";缺验证,它会在错误的路上狂奔到预算耗尽,还告诉你一切顺利。
所以评估一个 Agent 框架,别看它支持多少模型、接了多少工具,看三个地方就够了:上下文是怎么拼的(决定成本与稳定性)、工具是怎么管的(决定安全与能力上限)、产物是怎么验的(决定可不可信)。把这三处做扎实,剩下的都是工程细节;三处里任何一处偷懒,上层包装再花哨也补不回来。
最后给完全上手的人一句实操建议:先写那个验证脚本,再写 Agent。因为你先有了"什么叫对"的定义,才可能让一个只会输出 token 的东西,真的把事做成。