Part 5 工程化:把玩具变成生产级工具
墨叔:老哥,前面四 Part 咱们把”agent 是什么、怎么想、怎么动手、怎么记”讲透了,但那些还都是实验室里的玩具。这一 Part,墨叔要带你把玩具拧成生产级工具——状态能恢复、输出能丝滑、账单能算清、运行能观测、危险动作有人拦。这五件事,件件都是你后端功力的用武之地。
5.1 状态管理与可恢复性:agent 不是一次性脚本
老哥,你先想一个场景。你让 agent 干一件大事:“把咱们的支付模块从单体里拆出来,重构成独立服务,顺便把相关的二十九个调用方改完。“这种活儿,agent 得转几十圈、调几百次工具、跑个十几分钟。现在我问你一个扎心的问题:跑到第 27 圈、正在改第 18 个文件的时候,你的进程被 OOM killer 干掉了,或者你手滑关了终端——你重新跑,是从头再来,还是能从第 27 圈接着干?
你说:“那肯定得从头再来啊,不然呢?“——如果你脱口而出这句,恰恰说明你还在用”脚本思维”看 agent。而生产级的 agent,答案必须是:从最后那个干净的状态接着跑。
为什么?因为 agent 干的是长程、有状态、带副作用的活。它每转一圈,工作台(上下文窗口)里都积累了一堆”我改了什么、看到什么、下一步打算怎么走”的状态。把这堆状态丢掉重来,不仅浪费钱(重新跑的 token 你买单),更致命的是——它可能已经改了一半的文件,重来一次可能把已经改对的又改乱,甚至产生不一致的半成品。这和你在线上跑一个跑了半小时的数据库迁移脚本,中途断了从头跑,是两码事:迁移脚本有事务回滚,agent 的”改文件”可没有。
所以这一节,墨叔要跟你聊的,就是怎么让 agent 变得”可恢复”。核心就三个词:会话持久化、checkpoint(检查点)、断点续跑。
先说”会话持久化”。你回头看 Part 1 那个最简循环,每次决策都靠 messages 这张不断变长的纸条。这张纸条,就是 agent 的全部”记忆”和”状态”。生产级 agent 第一件事,就是把这张纸条落盘——每产生一条新消息(模型的思考、工具调用、工具返回),就 append 到一个文件里(通常是 JSONL,一行一条事件)。这样哪怕进程崩了,纸条还在磁盘上。你重新启动,把磁盘上的纸条读回来,agent 就能”回忆起”自己跑到哪了。
老哥,你品一品这个操作你熟不熟。你在后端写过”把请求处理过程记到 append-only 日志”吗?写过”用 binlog / WAL 恢复数据库状态”吗?会话持久化,本质就是把 agent 的运行日志当成它的”重放源”——和事件溯源(Event Sourcing)一字不差。事件溯源是说:别存”当前状态”,存”导致当前状态的那一串事件”,需要时把事件重放一遍就得到状态。agent 的 messages 就是那串事件,模型每次从头读整串来决策,这不就是重放吗?
再说”checkpoint”。光把纸条落盘还不够。长任务里,agent 可能做了很多”中间成果”——比如它已经把十几个文件改对了,但还没提交。如果这时候崩了,重放纸条只能让你”想起走到哪”,但磁盘上那十几个改动是真实的文件状态,你得保证它们和纸条里记的”我改了哪些”对得上。checkpoint 就是在这个节点做一个一致性快照:“此刻,工作区状态 = X,上下文 = Y,下一步计划 = Z”。它可以是 git 的一个临时 commit,可以是一个存了文件路径加哈希的清单。崩了之后,你先恢复到最近的 checkpoint(文件状态对齐),再重放这之后还没落盘的纸条,就能无缝续上。
这又是个你熟透的概念:checkpoint = 数据库的 savepoint / 虚拟机的快照。你做线上变更前先打个快照,出问题回滚,一个道理。区别在于 agent 的 checkpoint 频率得你自己定——太频繁,存快照的开销吃掉效率;太稀疏,崩了要重做的活太多。经验值通常和”副作用粒度”对齐:每完成一个”不可逆或成本高”的动作(比如改完一个文件、跑完一次测试),就值得打一个。
最后”断点续跑”。这三件套合起来,目标就是让 agent 在任意中断后能接着走。工程上怎么实现?其实就是在循环启动时,先判断”有没有上次没跑完的会话”:有,就 load 回来,从最后一条消息之后继续;没有,才初始化新会话。你甚至可以做到”用户主动暂停、明天接着干”——这对那些跑几个小时的大活儿至关重要。
给你一个真实的连接。Claude Code 就明明白白地支持 resume:你上次会话的历史以 JSONL 形式存在本地,下次启动时它能列出历史会话让你选一个接着聊。你以为它”记住了你”,其实它只是把纸条存好了、读回来了。你当年做”服务优雅退出 + 重启后从队列恢复消费位点”,用的就是同一套心智——offset 就是 checkpoint,消息队列就是持久化的纸条。你不是新手,你只是还没把这套心智套到”模型决策”上。
我给你一段示意,把持久化和 checkpoint 钉进代码里。注意看 save_event 和 restore_session 这两个函数——它们就是”纸条落盘”和”读回纸条”。
# Agent 状态持久化与断点续跑(示意,非可运行)
import json, os
SESSION_FILE = "agent_session.jsonl" # 一行一条事件,append-only
def save_event(role: str, content: str):
"""每产生一条消息,立刻 append 到磁盘——这就是'会话持久化'。"""
with open(SESSION_FILE, "a", encoding="utf-8") as f:
f.write(json.dumps({"role": role, "content": content}) + "\n")
def restore_session() -> list:
"""启动时读回纸条:崩了也能'回忆起'跑到哪。"""
if not os.path.exists(SESSION_FILE):
return []
with open(SESSION_FILE, encoding="utf-8") as f:
return [json.loads(line) for line in f if line.strip()]
def make_checkpoint(label: str):
"""在'成本高/不可逆'的节点打快照,比如 git 临时 commit。"""
os.system(f'git commit -am "checkpoint: {label}" >/dev/null 2>&1')
save_event("system", f"[checkpoint] {label}")
def agent_loop_resumable(goal: str, tools):
messages = restore_session() # 先试着读回上次的纸条
if not messages:
messages = [{"role": "system", "content": "你是一个助手"},
{"role": "user", "content": goal}]
for step in range(50):
resp = model_chat(messages, tools=tools)
if resp.is_final_answer:
save_event("assistant", resp.text)
return resp.text
save_event("assistant", resp.raw) # 思考落盘
result = tools[resp.tool_call.name](**resp.tool_call.args)
save_event("tool", f"[{resp.tool_call.name}] {result}") # 结果落盘
if step % 5 == 0: # 每 5 步打一个 checkpoint
make_checkpoint(f"step-{step}")
messages.append(...)老哥,你盯住 restore_session() 和循环开头那几行——“先读回纸条,没有才初始化”,就这一句,agent 就从”一次性脚本”变成了”可恢复的服务”。你写 Go 时 recover() 加重放日志也是这个味道。
再说一个老哥你容易忽略的点:持久化要分清”状态”和”副作用”。纸条(messages)是状态,要全量落盘;但 agent 改过的文件、跑过的测试,是副作用,它们是真实存在于磁盘与系统里的,不需要”重放”去重建——你只要记一个 checkpoint 指向”此刻文件长这样”就够了。反过来,如果你把副作用也当成事件去重放(比如重放”编辑文件”动作),就可能和真实磁盘状态冲突。所以工程上的铁律是:状态用事件溯源重放,副作用用快照对齐,二者别混。很多人第一次做断点续跑,就栽在”重放了不该重放的副作用”,把已经改对的文件又改了一遍。
我给你一个真实味道的案例。有个团队让 agent 半夜跑”全库字段脱敏”的大活,跑了两小时,到第 180 圈时容器被调度器驱逐。他们没做持久化,只能从头重跑——结果第二次跑到一半,发现第一批已经脱敏的数据被当成”未脱敏”又脱了一次,部分字段双重加密、不可逆。如果有 checkpoint,从 180 圈恢复,就不会有这出。这个坑,本质就是你后端”幂等消费加 offset 提交”没做好的翻版:kafka 消费你都会先处理、再提交 offset,agent 你也得先落盘状态、再继续下一步,否则就是”处理了但没记录”,重启就重复处理。你早懂这个道理,只是没把它用在”模型决策流”上。
还有一层老哥要想透:断点续跑不是所有任务都该续,得看”任务是否还成立”。比如 agent 跑的是”根据今天股价给建议”,凌晨崩了、早上恢复,可”今天”已经变了,硬续出来的结论是基于过时前提的——这种就该丢弃会话重开,而不是傻续。所以恢复逻辑里要有一道”目标有效性检查”:恢复前先问”用户原始目标还成立吗、外部前提变了吗”,成立才续,不成立就提示用户重来。这和你后端”消费者重启后先校验 offset 对应的数据是否还新鲜、过期就重算”是同一谨慎。可恢复是能力,但”盲目续”是有害的,把这道开关交给你设计,别默认永远续。
更深一层,可恢复还解锁了一个你之前想不到的产品形态:跨会话的人机协作。因为状态落盘了,你今天让 agent 干到一半下班,明天打开接着聊,它记得昨天走到哪;甚至你同事接手你的会话文件,就能从你的断点继续。这把 agent 从”一次性问答”变成”可持续推进的项目伙伴”。你后端做”工单状态机 + 历史流转记录”,让不同人接力处理同一工单,和这同构——会话文件就是那个可交接的工单。状态可恢复,协作才可能发生,这是工程价值,也是产品价值。
【思考题】
- 把 agent 的
messages当成事件溯源的”事件流”,和直接存”最新状态快照”相比,各有什么利弊?agent 为什么更适合前者? - checkpoint 打得太频繁和太稀疏,分别会出什么问题?如果让你给”改文件”类任务定一个 checkpoint 策略,你会怎么定?
- 断点续跑时,如果崩溃点恰好在”模型已经决定调一个工具、但工具还没执行”的中间态,你恢复后应该重放这个未执行的决策吗?为什么?
【参考答案】
- 存事件流(messages):利在”完整可审计、可任意回放任意时间点、天然支持’接着聊’“,因为模型每次就是从整段事件流决策;弊在”重放成本高”——每轮要把整段历史喂给模型,越长越贵越慢。存最新快照:利在”恢复快、省 token”;弊在”丢了中间过程、无法审计、且快照本身可能和真实副作用不一致”。agent 更适合事件流,根本原因是模型是无状态的,它每次决策必须重放全部上下文才能连贯——你没法只给它”最终状态”就让它接着干,因为它不知道”怎么走到这的”。这和事件溯源的数据库(如用事件重建读模型)是同一个取舍。
- 太频繁:快照本身有开销(git commit、文件哈希计算、写清单),且 checkpoint 多了管理复杂、恢复时选哪个也头疼,还会拖慢主流程。太稀疏:崩了要重做的活太多,既浪费钱又可能让已做的副作用处于”半完成”的危险态。我的策略:checkpoint 和”不可逆/高成本副作用”对齐——每成功改完一个文件并验证、每跑完一轮测试、每完成一个子任务,就打一个;纯”只读”动作(读文件、查数据库)不打,因为重做成本极低。本质是”在副作用的边界上留退路”,和你做变更前打快照同一逻辑。
- 不该无条件重放,而要判断这个未执行决策是否还有效、执行它是否安全。因为”模型决定调工具”这个意图,可能基于崩溃前那一刻的上下文;崩溃期间外部世界可能变了(比如那个文件被别人改了、那个 API 已经返回了)。正确做法是恢复后,把”我上次要调 X”作为一个待确认项,重新把当前真实状态喂给模型,让它决定”还调不调、参数还对这些不”。这对应后端里”幂等与状态机”的思想:恢复后不盲目重放动作,而是先校验前置条件。盲目重放一个”发邮件”或”删文件”的决策,可能酿成重复副作用——所以未落地的决策要谨慎,已落地的副作用要对齐。
小结:agent 不是一次性脚本,长程任务必须可恢复,靠”会话持久化(纸条落盘,即事件溯源)+ checkpoint(一致性快照)+ 断点续跑(启动先读回纸条)“三件套。你后端那套 WAL、savepoint、消费位点恢复,正是这套心智的来源。
5.2 流式输出与交互体验:为什么 Claude Code 丝滑
老哥,你先回忆一下你用 Claude Code 的感觉。你敲一句”帮我把这个 bug 修了”,它没让你干等,而是屏幕上开始一行行冒东西:先说”我去看一下报错在哪”,然后”读了一下 auth.go 第 47 行”,然后”我改一下这里”,最后”改完了,跑个测试”——整个过程你全程看得见它在干嘛,而且你想打断随时 Ctrl-C,它立刻停。你再对比一下另外一些 agent:你输入完,它转了四十秒,屏幕上一片死寂,你怀疑它是不是卡了,正想关掉,它”啪”地吐出一大段结论,你才发现它其实在干活。
这两种体验的差距,就是这一节要聊的:流式输出与交互体验。墨叔先抛个问题:同样是等四十秒,为什么”一个字一个字蹦”让你觉得快,而”转四十秒憋一大段”让你觉得慢甚至想关掉?
答案藏在人的心理和工程的真相里。先说心理:人对”没反馈的等待”容忍度极低。屏幕上有东西在动,你的大脑就判定”它在干活,没死”;一片死寂,你的大脑就判定”可能挂了”。这跟你在终端跑 npm install 时,它一行行刷进度条你就安心,卡在 fetchMetadata 不动你就想 kill,是一模一样的。流式输出本质是把”内部进度”外化成”可见的心跳”。
但流式不只是心理安慰,它是工程真相的暴露。模型生成回复,本来就是一个 token 一个 token 蹦出来的——它不是”想好了整段再吐”,而是边预测边吐(还记得 Part 1 说的”下一个 token 预测”吗?)。所以”憋一大段再给”反而是人为的:你的代码把模型吐出的 token 全攒着,等它喊停了再一次性返回。这中间你既没让用户看到进度,又白白占着连接。而流式,是老老实实把模型生产 token 的节奏,原样转给用户。
技术上这叫 Server-Sent Events(SSE)或 chunked transfer(分块传输)。你后端熟啊——你给前端做”下载进度""日志实时滚动”,用的就是 SSE 或 WebSocket 把数据一块块推过去。agent 的流式输出,底层一模一样:模型 API 返回一个”流”(stream),你每拿到一个 chunk(一小撮 token),就立刻 yield / push 给前端渲染,前端把它追加到屏幕末尾。模型在”生产”,网络在”运输”,屏幕在”渲染”,三者并行,用户看到的永远是”最新生产出来的部分”。
老哥,这里有个关键设计点你要品:增量渲染 + 可打断,才是丝滑的完全体,光流式不够。流式让你看到进度,但如果你不能打断,agent 已经跑偏了你只能干瞪眼等它跑完。Claude Code 的 Ctrl-C 之所以好用,是因为它的循环每一轮都检查”用户有没有打断”——用户一按 Ctrl-C,循环捕获信号,立刻停在当前步,把”半成品”交还给用户,用户可以纠正指令再继续。这叫”人类随时能插手”,是 agent 交互体验的及格线。
我给你一个生活化类比。agent 像个装修队在你家干活。非流式的体验是:你把钥匙给他们,门关上,四十分钟后来开门——中间你完全不知道是拆对了还是拆错了墙,急得团团转。流式的体验是:门开着,你站在门口,看他们”先量尺寸""再拆这块""哎这块拆错了重来”,而且你随时能喊”停,这块别动”。哪个你安心?显然是后者。而且后者还有个隐藏好处:你能及早纠偏,不至于等它干完一通才发现方向错了,返工成本翻倍。
更深一层:中间状态可见,还解决了”信任”问题。agent 是个概率程序,你不知道它下一步会干啥。如果它把所有动作藏起来、最后给你个结论,你既不放心也没法介入;如果它每步都摊开(“我将要调工具 X,参数是 Y”),你就能在危险动作发生前喊停。这把 Part 2 讲的”副作用要谨慎”、Part 5.5 要讲的”人类确认”,在体验层先兑现了一半——可见,才有可控。
给你一个真实的连接。你做过后端长任务接口吧?比如”导出十万条数据成 CSV”。非流式做法是接口里跑完整个导出、攒成文件、最后返回;用户体验是转圈转到怀疑人生。正确的做法是:用 SSE 一边查一边推进度(“已处理 30000/100000”),前端实时画进度条,甚至推一部分数据就渲染一部分。agent 流式输出,就是你这套”长任务进度外化”经验的升级版——只不过进度不是”处理了 N 条”,而是”模型的思考流 + 工具调用流”。你早就懂怎么让用户不焦虑,现在只是把”进度源”从”循环变量 i”换成”模型吐出的 token 流”。
我给你一段流式读取响应的示意。这就是模型 API 返回 stream 时,你该怎么一块块往外送。
# 流式读取模型响应并增量渲染(示意,非可运行)
def stream_agent_reply(prompt: str, on_token, on_tool_call):
"""on_token: 每收到一点文本就回调(增量渲染);on_tool_call: 模型决定调工具时回调。"""
# 模型 API 支持 stream=True,返回一个'逐 token 吐出'的迭代器
chunks = model_chat_stream(messages=prompt, tools=TOOLS, stream=True)
buffer = "" # 累积当前这段文本
for chunk in chunks:
if chunk.is_text:
buffer += chunk.text
on_token(chunk.text) # 立刻推给前端,屏幕多几个字
elif chunk.is_tool_call_start:
on_tool_call(chunk.tool_name, chunk.args) # 立刻显示"我要调X"
# 关键:循环在这里能被'打断信号'中断(Ctrl-C / 前端发停止)
if user_pressed_stop():
chunks.close() # 关掉流,agent 停在当前步
return {"stopped": True, "partial": buffer}
return {"stopped": False, "full": buffer}老哥,你看 on_token(chunk.text) 那行——每蹦一个 chunk 就立刻回调渲染,这就是”字一个个往外冒”的来源。user_pressed_stop() 那行,就是 Ctrl-C 能打断的来源。这两行加起来,体验就从”死等”变成”丝滑且可控”。你写 Go 时用 ctx.Done() 检查取消,和这里的 user_pressed_stop() 是同一个意图:在循环的每轮(每个 chunk)都看一眼”还让不让我干”。
我再补一个工程细节,很多新手会栽:流式不是”模型返回啥就原样吐啥”,中间要做”结构化解析与节流”。模型在 stream 里吐出的,常常是一段”先说人话、再夹带一个工具调用 JSON”的混合流。你前端要渲染”人话”部分,但”工具调用 JSON”部分不能原样刷给用户(那是给循环解析的)。所以流式管道里得有个”分流器”:识别哪些是给人看的文本、哪些是给机器解析的结构,文本走渲染、结构走解析。这又是个你熟的概念——你后端做”响应体按 Content-Type 分流处理”,一模一样。漏了这步,用户就会看到一堆残缺的 JSON 碎片在屏幕上闪,体验比”憋大段”还糟。
还有个反直觉的点:流式优化的是”人的等待体验”,不优化”真实完成时间”。任务该跑十秒还是十秒,流式不会让它变快;它只是把”第一字节到达”的时间从十秒压到零点几秒,让你心里有底。所以别指望流式能省性能,真正省时间的是 Part 3 的并行工具调用、是 5.3 的上下文裁剪——别把两件事搞混。Claude Code 丝滑 = 流式(体验)加并行工具调用(真实快)加精准上下文(不绕弯),三层叠出来的,不是单靠流式。
最后点一个把 5.2 和 5.3 连起来的要害:流式还是你省钱的第一道闸,因为它让”早取消”成为可能。你想,agent 跑偏了,如果它憋大段、你干等四十秒才发现方向错,那四十秒里模型吐的几千 token、调的几十次工具,钱已经花了,退不回。但流式下你三秒就看到”它要删生产库”,立刻 Ctrl-C——后面那些本要烧的 token 根本没产生。可打断 = 及时止损 = 真金白银。所以”丝滑”不是玄学体验,是成本控制的工程手段:让人在最贵动作发生前能踩刹车。你后端做”请求超时主动 cancel 下游调用”省资源,和这同一个意图——只不过这里”下游”是模型自己,踩刹车的”你”是用户。
还有个工程细节叫背压(backpressure),你做流式后端躲不开:模型吐 token 太快、前端渲染太慢,中间的缓冲就会涨爆内存。所以流式管道里要”消费者驱动”——前端说”我准备好了再给下一块”,模型流就暂停推送。这和你用 Go channel 做”带缓冲的生产者消费者、满了就阻塞”一模一样。漏了背压,长输出任务可能把前端卡死。丝滑体验的底下,全是这些你早已熟悉的后端机制在撑,只是这次的”生产者”是模型、“消费者”是屏幕。
【思考题】
- 模型是”边预测边吐 token”的,那”攒一堆再返回”和”流式返回”,对模型侧的生成过程有影响吗?还是只影响你这边的传输?
- 如果流式输出让你”随时能打断”,那打断之后 agent 的上下文(那张纸条)里应该怎么记这笔账?要不要留一句”用户在第 N 步打断了”?
- 增量渲染如果做得不好(比如工具返回一大段突然刷屏),反而会制造噪声。你作为用户,最希望”看见”的是哪类中间状态,“不想被刷屏”的是哪类?从设计角度怎么取舍?
【参考答案】
- 对模型侧的生成过程基本没影响——模型该一个 token 一个 token 预测,还是那样预测;区别在于你这边的”运输与呈现”层。“攒一堆再返回”只是你的客户端代码把 stream 收齐了才显示,模型侧该多快还是多快,你反而白白浪费了”前段 token 早已产生却没给用户看”的时间。所以流式是纯收益:不增加模型开销,却把等待感切成碎片、让用户安心。唯一例外是某些”要不要开流式”会影响采样策略的实现细节,但对你设计 agent 而言,流式是默认该开的,没有理由关。
- 必须留。打断本身就是一次”用户干预事件”,它和工具调用、工具返回一样,是该写进纸条(messages)的一条事件。理由有二:一是模型下一轮决策需要知道”我上次被叫停了,用户可能不认可那个方向”,否则它会自顾自接着原来的思路跑;二是审计和可恢复——你后面做 eval、复盘”为什么这轮跑偏”,这条”用户打断”是黄金线索。正确做法是打断时,往 messages 里 append 一条
{"role":"user","content":"[用户在第N步打断,请说明你刚才的意图并等待新指令]"},把控制权交还用户。这和你在长任务里记录”用户取消了导出”同理,都是状态的一部分。 - 最该”看见”的是决策类中间状态:模型”我打算调工具 X、参数是 Y”、它”现在在读哪个文件”、它”卡在哪一步了”——这些让你有掌控感、能及早纠偏。最该”别刷屏”的是工具返回的原始大文本:比如读一个 2000 行文件,全屏刷出来既浪费你注意力又稀释关键信息。设计取舍就一条原则:决策透明、数据折叠。模型的意图和动作(动词加宾语)默认全展示;工具返回的具体数据默认折叠(只显示”已读 auth.go,发现第47行空指针”这种摘要),用户想看细节再展开。这恰好呼应 Part 4 的”上下文工程”——给用户看的,和给模型吃的,都要做提炼。
小结:流式输出 = 把模型”逐 token 生产”的节奏原样外化给用户,本质是 SSE/chunked 那套长任务进度经验的升级。光流式不够,必须配”增量渲染 + 随时可打断 + 中间状态可见”,才构成可控的丝滑体验。可见,才有可控;可控,才有信任。
5.3 成本、延迟、限流:生产级 agent 的隐藏账单
老哥,我问你个扎心的问题:你那个 agent 跑完一个”重构支付模块”的任务,到底花了多少钱?你说”不就调几次模型 API 嘛,几毛钱?“——墨叔告诉你,真到生产级,一个复杂任务烧掉几美元甚至几十美元,一点不稀奇。而且这账单里绝大部分是你没想到的隐性开销。这一节,咱们把这张隐藏账单摊开看。
先说最基础的:token 计价,输入和输出都算钱。你还记得 Part 1 说的,模型是无状态的,每次决策都要把整张纸条(messages)重新喂进去吗?那张纸条有多长,每次调用就收你多少”输入 token”的钱。而模型吐出来的回复、工具调用指令,是”输出 token”,另算一份、通常更贵。关键点来了:agent 转的每一圈,都要重新为整张纸条付一次输入钱。转 30 圈,同一段历史上下文被付了 30 次钱。你以为你只”问了一次”,模型账单上记的是”30 次完整上下文输入”。
我给你个生活化类比。这就像你每次跟外包沟通,都要把整个项目背景从头讲一遍,而且按字数收费。第一次讲背景 1000 字,付 1000 字钱;第二次他干完一步回来问你,你又得把 1000 字背景重讲一遍(因为他不记得),再付 1000 字;转 30 圈,背景被讲了 30 遍,钱也付了 30 遍。你肯定想:“能不能只讲增量?“——可惜模型 API 目前基本不支持”我只传差异”,它每次都要整段上下文。所以上下文越长,每圈的单价越高,圈数越多,总价指数级往上爬。这就是长上下文的”隐性开销”:不是某一笔贵,是每笔都被放大。
再说并发与重试如何放大成本。你做后端知道,为了吞吐会上并发、为了可靠会上重试。agent 里这两样也是双刃剑。并发:你同时起 5 个 agent 处理 5 个任务,模型调用 QPS 翻 5 倍,账单也翻 5 倍——这是显性的,你心里有数。但重试是隐蔽的放大器:模型调工具失败了,你的循环自动重试 3 次;或者模型输出不稳定,你为了”更稳”让它对同一决策采样 3 次取最优(多数投票)。每一次重试,又是一次完整上下文的输入钱。一个本该 1 美元的任务,加了 3 次重试加 5 路并发加长上下文,轻轻松松变成 15 美元。你当时只想着”稳一点”,账单不会提醒你。
还有限流(rate limit)。模型 API 不是无限供应的,它有每分钟 token 数、每分钟请求数的上限。你的 agent 如果疯狂并发、疯狂重试,很容易撞墙,撞了要么报错要么退避重试(backoff)——退避又拖长延迟,延迟又可能触发你的”超时重试”,又是一圈成本。限流不是”别人的问题”,它直接决定了你的并发上限和重试策略,是成本和延迟之间的一个硬约束。
那怎么在”效果”和”成本”之间取舍?墨叔给你几条工程上的刀法:
第一,上下文要做减法。前面 Part 4 反复讲,工具返回要截断、对话要摘要、无关历史要丢。这不只是为了模型不”失焦”,更是为了每圈少付钱。一个被压到 8k token 的上下文,比 60k 的,每圈成本差近 8 倍——转 30 圈就是差 8 倍的总价。把”上下文工程”当成”成本控制”来做,一举两得。
第二,重试要分级、要设上限。不是所有失败都值得重试 3 次。工具超时可以重试(可能是 transient),但”模型决定删库被护栏拦下”这种,重试也是拦下,纯浪费。给重试设上限(比如最多 2 次)、给退避设上限(别指数退避到几分钟),否则一个卡死的循环能把你钱包拖垮。
第三,用便宜模型做”脏活”。agent 里很多步骤不需要顶级模型:比如”这个工具返回太长,帮我总结成三句话""判断下一步该调哪个读文件工具”,用个小模型甚至规则就能干,没必要每次都请最贵的那个。分层用模型(orchestrator 用强的,执行/总结用弱的),是成熟 agent 系统的标配省钱法。Claude Code 这类工具内部也是多档模型配合,不是全程一个顶级模型。
第四,缓存能救命。很多模型 API 支持”上下文前缀缓存”:如果你每次调用都带同一段超长的系统提示(定义它是谁、能调什么工具),这段可以被缓存、按折扣计价。你把不变的系统提示放前面、变的部分放后面,就能吃上缓存红利——一笔不小的节省。
给你一个真实的连接。你做后端算过”一次请求经过多少中间件、每个中间件加多少毫秒和多少机器成本”吧?agent 的成本账单,就是那个思维的变体,只是计价单位从”CPU 毫秒”变成”token 数”,放大器从”QPS”变成”圈数 × 上下文长度”。你当年做”降级、限流、缓存”是为了省钱保稳定,现在做 agent 的”上下文裁剪、重试上限、模型分层、前缀缓存”,是同一个省钱心智,只是战场换到了 token 上。你不是不会算账,你只是还没把”token”当成你的新货币。
我给你一段”成本估算器”的示意,让你对账单有体量感:
# Agent 单次任务成本估算(示意,非可运行)
def estimate_cost(ctx_tokens: int, steps: int, out_per_step: int,
in_price=0.003, out_price=0.015):
"""ctx_tokens: 上下文长度;steps: 转的圈数;out_per_step: 每圈输出 token。"""
# 每圈都为'整段上下文'付一次输入钱,转 steps 圈 → 输入钱被乘 steps 次
input_cost = ctx_tokens * in_price / 1000 * steps
output_cost = out_per_step * out_price / 1000 * steps
return {"input_cost": input_cost, "output_cost": output_cost,
"total": input_cost + output_cost}
# 例子:上下文 60k,转 30 圈,每圈输出 500 token
print(estimate_cost(60000, 30, 500))
# input_cost 约 5.4 美元,output_cost 约 0.225 美元/圈×30,总价轻松上 6 美元老哥,你看 ctx_tokens * ... * steps 这一行——上下文长度乘以圈数,就是成本的主引擎。把 60k 压到 8k,这一步就省掉 86% 的输入钱。这就是为什么我反复强调”上下文要裁剪”:它既是效果问题,更是钱的问题。你写 Go 时”减少一次无用 RPC 能省多少”的直觉,平移过来就是”每圈少喂 1k token 能省多少”。
老哥,我再点一个你做后端一定会共鸣的盲区:延迟和成本是同一个旋钮的两头。你想让 agent 更快,常会加并发、加重试、用更强的模型——这三样每一个都在加钱。反过来想省钱,裁上下文、降并发、用小模型——又可能让它更慢、更笨。工程上你不是在”选快还是选便宜”,而是在给每个任务定一个预算和 SLA:这个重构任务允许烧 5 美元、允许跑 15 分钟,那就把上下文压到 8k、并发设 3、危险动作走 HITL;那个”帮我解释这段报错”的小活,允许花 2 美分、要求 3 秒内回,那就用最小上下文、单路、弱模型。你当年给每个接口定”超时时间加资源配额”,和这完全一致——只是配额的单位从”CPU 核”变成了”token 预算”。
再说一个血淋淋的真实案例。有个团队把”客服 agent”上线,没限制上下文和重试。某天用户问了个开放问题,agent 陷入”调工具→看结果→又调”的循环,加上他们对每个失败都重试 3 次、还对”更稳妥”采样 3 次——单轮对话在 40 分钟内烧掉 200 美元,触发了信用卡限额告警。根因就是 5.1 说的”没有步数上限”加 5.3 说的”重试无上限”同时失效。修法朴素到你耳熟:加 max_steps 兜底、重试上限设 2、长对话自动摘要压缩上下文。这三道闸一上,单次对话成本从几十美元压到几美分。你看,agent 的成本事故,根子全是后端那套”限流、超时、降级”没到位。
顺带一句,成本这章最该刻进你脑子的一句话是:模型的单价每天都在降,但”圈数乘上下文”这个乘法结构不变。今天 60k 上下文跑 30 圈贵,明天单价降十倍,可你若把上下文涨到 200k、圈数涨到 100,账单照样爆。所以省钱的功夫不在”等模型变便宜”,而在”把乘法结构的因子压住”——上下文裁剪、步数上限、重试分级,这些是结构性杠杆,和单价无关。你当年做性能优化也懂:别指望”机器变快”解决架构浪费,得从”少发一次 RPC、少查一次库”的结构上抠。agent 的成本优化,是同一句老话的新版本。
【思考题】
- 模型 API 目前基本不支持”只传上下文差异”,这为什么让 agent 的成本结构天然是”圈数 × 上下文”?如果未来支持增量上下文,会怎么改变成本模型?
- 重试到底是”提升效果”还是”烧钱”?你能设计一个规则,判断哪些失败值得重试、哪些不值得吗?
- “用便宜模型做脏活”这个分层思路,和你后端”把重活和轻活分服务、分机器”是不是一回事?区别在哪?
【参考答案】
- 因为模型无状态,每次调用必须喂完整上下文才能决策,而 agent 每圈都调用一次,所以”完整上下文”被重复付费
steps次——成本约等于单圈上下文价 × 圈数,是乘法不是加法。若未来支持”只传差异 / 服务端记住上下文句柄”(类似你维护一个 session 不全量传),那每圈只需付”新增那几百 token”的钱,成本结构会从”圈数乘全长”塌缩成”总长加增量乘圈数”,长任务的成本会断崖式下降。这也解释了为什么”前缀缓存”这么重要——它是在”不得不传全长”的现实下,用缓存折扣逼近”增量”的效果。本质就是后端里”无状态请求每次带全量 payload”和”有状态连接只带增量”的取舍。 - 重试是双刃剑:对 transient 失败(网络抖动、工具超时、限流 429)能提效果且不冤;对 deterministic 失败(参数写错被 schema 拦、危险动作被护栏拒、模型逻辑本身就错)重试纯烧钱且无效,因为同样输入必然同样失败。我的规则:只重试”可能自愈”的失败——超时/限流/连接错(这类重试有成功率);不重试”语义被拒”的失败——直接把错误读回纸条让模型换思路,而不是让它撞同一堵墙。再叠加”重试上限不超过 2 次 + 总步数上限”,既保效果又封住成本黑洞。这和你后端”对 5xx 重试、对 4xx 不重试”的纪律完全一致。
- 是同一个”分而治之、按质定价”的心智,但落地不同。后端分服务分机器,是因为不同负载对 CPU/内存/延迟的需求不同,按资源定价;agent 分层用模型,是因为不同”认知难度”对模型能力的需求不同,按智力定价。区别在:后端你清楚知道”这个接口重”,能静态划分;agent 里”这步需不需要强模型”往往是动态的——同一个 agent,读文件那步用弱模型,做规划那步用强模型。所以 agent 的分层更细、更动态,常在同一循环里切换模型档位。你当年的”读写分离、冷热分离”,正是这种思维的雏形,现在升级成”聪笨分离”。
小结:agent 的隐藏账单 = 圈数 × 上下文长度 × 单价,输入和输出都计费,长上下文是最大放大器;并发和重试会隐蔽地放大成本,限流是硬约束。省钱的刀法和你后端”降级限流缓存”同源:裁上下文、限重试、分层用模型、吃前缀缓存。
5.4 可观测性:日志、trace、eval——没有观测就没有迭代
老哥,你先想一个你最怕的场景。线上服务出问题,你第一反应是 kubectl logs 或者点开 tracing 面板看哪一段慢了、哪个调用报错了。现在换成你的 agent:用户说”它今天改代码改出 bug 了”,你打开终端——发现它啥日志都没留,只有最后一句”改完了”。你根本不知道它调了哪些工具、读了哪些文件、在哪一步开始跑偏。你只能凭记忆猜。墨叔问你:这时候你是什么心情?
你说”那我给它加打印啊”。加 print 是对的直觉,但远远不够。生产级 agent 的可观测性,不是”随便打几行”,而是把每一次运行变成一条可回放、可度量、可对比的轨迹(trace)。这一节就聊这个:为什么没有观测就没有迭代,以及 trace 怎么串起一次 agent 运行。
先说为什么这么要命。你写确定性服务时,bug 是可复现的:curl 一下同样的请求,服务行为一样,你单步调试就能定位。但 agent 是概率程序(Part 1 讲过)——同样的目标,它今天走这条路径、明天走那条,你没法”复现那个错”。所以你不能靠现场调试,只能靠事后对”轨迹”的回放分析。轨迹就是你唯一的”案发现场录像”。没有它,每次出问题的 agent 都是一次没有任何线索的悬案,你永远在猜。
可观测性在 agent 里分三层,我一层层讲。
第一层,日志(logging)。最基础:每一步发生了什么,记下来。但 agent 的日志不能只是”INFO: step 3 done”,而必须是结构化的:这一步模型调了哪个工具、传了什么参数、工具返回了什么、耗时多少、花了多少 token、模型给了什么思考。每一项都是后面分析的弹药。你后端打结构化日志(JSON 日志、带字段)就是这个意识——别打”一句话”,打”带字段的事实”。
第二层,trace(链路追踪)。这是核心。一次 agent 运行,本质是一棵”动作树”:根是你给的目标,下面是模型的若干决策(span),每个决策可能触发工具调用(子 span),工具返回又喂回模型产生新决策(兄弟 span)……你要用唯一的 trace_id 把这一次运行的所有 span 串起来,每个 span 带起止时间、输入输出、token、状态。这样你就能像看后端分布式调用链一样,看”这次运行里,哪一步最慢、哪个工具调用最贵、模型在哪一步开始胡说”。
老哥,这个你太熟了。你做微服务用 OpenTelemetry / Jaeger,一个请求跨五个服务,靠 trace_id 加 span_id 串成一条瀑布图,哪段红了一目了然。agent 的 trace 结构完全一样,只是”服务”换成了”模型的决策和工具”:模型调用是一个 span,工具执行是一个 span,它们在同一个 trace_id 下父子嵌套。你当年排”为什么这个请求 2 秒”,靠的是 trace;现在排”为什么这个任务又跑偏又烧钱”,靠的也是 trace。心智零成本迁移。
第三层,eval(评估)。这是把”观测”变成”迭代”的闭环。光看单次 trace 只能救火,不能进步。你得把一批标准任务(比如”修这 20 个已知 bug""重构这 10 个模块”)定期跑,看成功率、看平均步数、看平均成本,做成面板。哪周成功率掉了,你回去看那批 trace 找共性。这就是 Part 9 要讲的 eval 的雏形——没有量化指标,你不知道 agent 是变好了还是变烂了。你后端做”监控面板 + SLO”,和这完全同构:trace 是显微镜,eval 是仪表盘。
我给你一个”trace 怎么串”的具体画面。一次运行:
trace_id=abc123
├─ span[目标] "重构支付模块" (0ms~12000ms)
│ ├─ span[模型决策#1] 思考"先读支付入口" (10ms~800ms, in 6k tok, out 200)
│ │ ├─ span[工具: read_file] auth.go (800ms~900ms, 返回 2000 字)
│ │ └─ span[模型决策#2] 思考"发现空指针" (950ms~1500ms)
│ │ ├─ span[工具: edit_file] (1500ms~1600ms, 成功)
│ │ └─ span[工具: run_test] (1600ms~8000ms, 失败: 3 cases)
│ └─ span[模型决策#3] 思考"测试挂了,看报错" ...
老哥,你看这棵树——trace_id=abc123 是根,下面每个决策和工具都是带时间的 span,父子关系就是”谁触发了谁”。你一眼能看出:run_test 那步花了 6.4 秒(最慢)、edit_file 之后测试挂了(可能改错了)。没有这棵树,你只知道”最后测试挂了”,不知道是 edit_file 那一刀切歪了。
再一个生活化类比。agent 像一艘自动驾驶的货轮,你不在驾驶室。可观测性就是这艘船的”黑匣子 + 航行日志 + 仪表盘”:日志是船员每半小时记的一笔(发生了啥),trace 是黑匣子记录的完整航迹(每一刻在哪、做了啥动作),eval 是月底的”本月准点率、油耗、事故率”报表。没有黑匣子,船撞了你就永远不知道是舵偏了还是浪太大;没有月报,你永远不知道这艘船是越开越稳还是越开越险。你做后端”不加监控不上线”,对 agent 要更狠:不加 trace 不叫 agent,叫盲盒。
给你一个真实的连接。你踩过”日志打得不全,线上出问题只能靠猜”的坑吧?agent 把这个坑放大十倍,因为它是概率的、路径不固定的,猜都没法猜。所以 agent 的可观测性不是”锦上添花”,是”能不能活下去”的底线。你当年搭 ELK / Loki / OTel 的那套投入,在 agent 这里一分钱都不浪费——把模型调用和工具调用当成你新的”服务”,用同一套 tracing 基础设施接进去就行。区别只在于:你多定义了两种 span 类型——llm_call 和 tool_call,其余的 parent/child、trace_id 传播,原样复用。
我给你一段”给 agent 步骤包一层 trace”的示意:
# 给 agent 每步记录 trace span(示意,非可运行)
import time, uuid
def traced_step(name: str, trace_id: str, fn, *args):
span_id = uuid.uuid4().hex[:8]
start = time.time()
print(f"[{trace_id}] span {span_id} START {name}") # 结构化日志
try:
result = fn(*args)
cost_ms = (time.time() - start) * 1000
print(f"[{trace_id}] span {span_id} END {name} ok {cost_ms:.0f}ms")
return result
except Exception as e:
cost_ms = (time.time() - start) * 1000
print(f"[{trace_id}] span {span_id} END {name} ERR {e} {cost_ms:.0f}ms")
raise
def agent_loop(goal, tools):
trace_id = uuid.uuid4().hex # 一次运行一个 trace_id
for step in range(50):
resp = traced_step("llm_call", trace_id, model_chat, messages, tools)
if resp.is_final_answer:
return resp.text
result = traced_step(f"tool:{resp.tool_call.name}", trace_id,
tools[resp.tool_call.name], **resp.tool_call.args)
messages.append(...)老哥,你看 trace_id 在循环外生成一次,每个 traced_step 都带它——这就是”把一次运行的所有 span 用同一个 id 串起来”。你写 Go 时用 trace.SpanFromContext(ctx) 取 span、用 context 传 trace_id,和这里的 trace_id 参数传递,是同一个意图:让分散的步骤共享一个”这次是谁”的身份。你早就懂怎么串链路,现在只是把”步骤”从”HTTP handler”换成”模型决策和工具”。
老哥,我再补一个把”观测”真正用起来的姿势,否则你记了一堆 trace 也只是仓库吃灰。关键在给 trace 打”标签”和”归因”:每次运行除了 trace_id,还要带”任务类型、用的模型档、上下文长度档、是否走了 HITL、最终成功与否”这些维度标签。这样你月底拉面板,能直接回答”哪类任务最爱跑偏""走 HITL 的任务成功率是不是更高""上下文超过 32k 时成本是不是失控”。没有标签的 trace 是散沙,带标签的 trace 是金矿——你后端做监控打 tag(如 endpoint、region、status)就是这意识,agent 只是多了 model_tier、ctx_size 这几个 tag。
还有一个真实案例,能让你记住”没观测多惨”。某团队 agent 上线后,用户偶尔抱怨”它改错了文件但不承认”。研发查了两周,加打印、加本地复现,都抓不到——因为问题是概率的,复现不了。后来他们接了完整 trace,回放一条失败轨迹,发现根因是:模型在某一圈读文件时,工具返回被截断了(Part 4 说的工具返回裁剪),它基于”半截内容”误判了函数签名,改错位置。没有 trace,这个”截断导致误判”的链路他们永远猜不到;有了 trace,一眼看到那一步工具返回末尾标着”[已截断]“,模型却当完整内容用了。观测不是锦上添花,是唯一能让你在概率世界里抓住确定因果的绳子。你当年靠日志抓”偶发空指针”,靠的就是这股劲,现在加倍用。
老哥,观测这事还有个和 5.3 成本挂钩的取舍,你得心里有数:trace 记太全,本身就是一笔 token 和存储账单。你每圈记”输入多少 token、输出多少、耗时多少”,这些维度本身要落库;长任务转几十圈,trace 数据量不小。所以生产上要”采样 + 分级”:普通任务抽样记录(比如每 10 次存 1 次完整 trace),但凡触发了 HITL、凡最终失败、凡成本超阈值的,必须全量留痕——因为这些才是你最该回放的”案发现场”。这和你后端”正常流量采样上报、错误流量全量保留”的 tracing 策略一字不差。别因为”要省钱”就把观测砍了(那是因小失大),也别”无脑全量”把观测本身变成成本炸弹——用采样把二者平衡,正是架构师的功力。
说到底,可观测性服务的不是”出了问题能查”,而是”没问题也能变好”。你每月看那张带标签的 eval 面板,发现某类任务成功率掉了两个点,顺藤摸瓜点开那批 trace,定位到是新版系统提示词让模型更爱调某个慢工具——你改一版提示词,下周成功率回来了。这条”面板→trace→根因→改→再看面板”的闭环,才是可观测性真正的回报。没有它,你的 agent 永远停在”能用但不确定有没有变差”的灰度里。你后端做”监控驱动优化”,和这同一条飞轮,只是转速换成了”任务成功率”。
【思考题】
- 为什么 agent 的 bug “不能靠现场复现调试”,而必须靠”轨迹回放”?这和确定性服务的调试哲学有什么根本不同?
- 如果让你给 agent 的 trace 定义 span,你会把”模型的思考过程”记成单独 span 吗?记太多 span 会有什么代价?
- eval(跑一批任务看成功率)和你后端”压测 + SLO 监控”是不是一回事?agent 的 eval 难在哪?
【参考答案】
- 因为 agent 是概率程序:同样目标,模型采样不同、外部状态(文件/网络/时间)在变,导致路径不固定,你没法”用同样输入复现那个错”。确定性服务的 bug 是”输入 X 必然输出错误 Y”,可单步调试定位;agent 的 bug 是”在某次运行中,模型在某步选了坏路径”,这次选坏、下次可能选好,现场抓不到。所以你放弃”现场复现”,改为”把每次运行全量录下来(trace),出事后回放那条具体轨迹找根因”。哲学差异:确定性世界靠”复现 + 断点”,概率世界靠”录像 + 统计”。这也是为什么 trace 对 agent 是生死线——它是唯一确定的案发现场。
- 模型思考该不该单独成 span,取决于你要看什么。我的做法:把”一次模型调用”作为一个 span(含输入 token、输出、耗时),思考过程是它的内容/属性,不强行拆成多个 span;但若一次决策里模型连续调了多个工具,那”决策 span”下挂多个”工具 span”是必须的。记太多 span 的代价:一是存储和开销爆炸(agent 转几十圈,span 可能上百,全量记录很贵,呼应 5.3 成本);二是噪声淹没信号,你排障时找不到重点。取舍原则:按”我想回答什么问题”来定 span 粒度——想看”哪步慢”就按步;想看”哪个工具贵”就按工具;别无脑全记。这和你后端”采样上报 trace”是一个节约思维。
- 同构不同难。同构:都是”用一批代表负载定期跑、看指标(成功率/延迟/错误率)是否达标”。难在三点:一是 agent 结果难判”对不对”——后端压测看 HTTP 200 就行,agent 看”它改的 bug 真修好了吗”需要人审或自动化测试,判定本身成本高;二是 agent 路径随机,同一任务跑两次结果可能不同,成功率是个分布不是定值,得跑多次取统计;三是成本和延迟随上下文浮动极大,eval 本身烧钱。所以 agent 的 eval 是”带成本约束的统计评估”,比后端 SLO 更重、更贵、更需抽样。这也是为什么 5.5 之后 Part 9 会把 eval 单独成章——它是 agent 工程里最被低估、最该投入的一环。
小结:agent 是概率程序,bug 不可现场复现,只能靠”轨迹回放”——所以可观测性是生死线。三层:结构化日志记事实、trace 用唯一 id 串起一棵动作树、eval 把观测变成仪表盘驱动迭代。你后端的 OTel/tracing/SLO 心智原样复用,只需新增 llm_call 和 tool_call 两种 span。
5.5 安全护栏:权限、沙箱、人类确认(HITL)
老哥,这一节是 Part 5 的收口,也是整本教程里最不能含糊的一节。我先问你一个你本能就会答的问题:你敢不敢在你生产服务器上,给一个”会随机出错、偶尔胡说”的程序,发一把 rm -rf 的权限?你肯定说”疯了吧,不敢”。好,那墨叔要追问:你现在写的 agent,如果没人拦着,它能不能 rm -rf、能不能 DROP TABLE、能不能给全公司发一封措辞离谱的邮件?
你说”我代码里没让它调这些工具啊”。——可 Part 2 讲过,agent 的能力边界 = 你暴露给它的工具集。只要你暴露了”执行 shell”或”发邮件”这类工具,而护栏又只靠”模型自觉”,那它在某个跑偏的循环里,真有可能触发。这里的关键认知:模型是概率的、会幻觉的,把”是否安全”寄托在它的”自觉”上,等于把保险箱钥匙挂在小偷够得着的地方。这一节,墨叔把 Part 2 那个”副作用与安全”在工程层面系统化,给你三道护栏:最小权限、沙箱、人类确认(HITL)。
第一道:最小权限(Least Privilege)。你后端熟透了——服务账号不给 root,数据库账号只给需要的库表,K8s 的 RBAC 按角色收口。agent 同理:给它干活刚好够用的权限,绝不多给。具体手法:工具集按”风险档”分级,agent 默认只能碰低风险工具(读文件、查日志、跑测试);中高风险工具(写文件、发请求)需要额外开关;致命工具(删库、外发、部署)默认根本不暴露给自动循环,只暴露给”人类确认后”的通道。你甚至可以做到”agent 运行在一个只有项目目录读写权、没有生产凭证”的账号下——它想删生产库,物理上就没权限。
第二道:沙箱(Sandbox)。再小的权限也可能有漏网之鱼,所以把 agent 的”动手”关进一个隔离环境。沙箱就是个”带围墙的操场”:agent 在里面能跑命令、改文件,但围墙外(你的真实系统、生产数据、网络)它碰不到。比如让 agent 在一个容器 / 临时虚拟机里干活,它改坏了自己那个小世界,重启就恢复,不伤你真机。这和你后端”不可信代码放隔离环境跑(如插件沙箱、eval 沙箱)“完全同构。Claude Code 这类工具默认就在你的项目目录内活动、且对敏感路径有保护,本质就是沙箱思想的简化版——用”工作目录边界”当围墙。
第三道,也是最硬的一道:人类确认(HITL, Human-In-The-Loop)。凡是”不可逆、影响面大、错了要命”的动作,模型只有”申请权”,没有”执行权”。循环在落地这类动作前,先暂停,把”我打算删这个文件 / 给这 100 人发邮件”摆到你面前,你拍板(yes)它才执行,你拒绝(no)它换思路。这就是 Part 1 说的”把必然正确从模型嘴里挪到你代码手里”的终极落地——不是你代码自动执行,是你这个人类亲自确认。注意 HITL 不等于”每一步都问你”(那体验崩坏、你也烦),而是”只在危险边界问你”。低风险动作全自动,高风险动作你拍板,这才叫丝滑且安全。
老哥,这三道护栏的层次你要品清楚:权限是”它能不能”,沙箱是”它搞砸了能伤多深”,HITL 是”致命动作谁拍板”。三层从”事前收口”到”事中隔离”到”关键时刻人把关”,是一个纵深防御(defense in depth)。你当年做安全,不也是”认证 → 授权 → 沙箱/隔离 → 关键操作二次确认”这一套吗?agent 的安全,没有新发明,全是把你后端那套防御性心智,从”防外部黑客”平移到”防内部概率程序犯浑”。
我给你一个生活化类比。agent 像个被临时授权进你家干活的电工。最小权限 = 你只给他电闸和要修那间屋的钥匙,仓库和保险箱钥匙不给;沙箱 = 你让他只在那间屋操作,别屋他进不去,就算他手滑把那屋的灯搞短路了,也就那屋的事;HITL = 他要动总闸(会全屋停电)前,必须先喊你”我准备拉总闸,行不?“你点头才拉。三道下来,你敢让他干活了。反过来,如果你把全屋钥匙加保险箱加总闸全给他,还指望他”自觉不乱动”——那不是信任,是赌命。
给你一个真实的连接,也顺手收一下 Part 2 的线。Part 2 讲”副作用与安全”时说过:不能随便让 agent 删文件,要幂等、要确认、要沙箱。那里是”道理”,这里是”工程落地”:道理变成三道具体的墙。你写过后端的”审批流”吧?比如”线上配置变更要主管审批”。HITL 就是把这个审批流,嵌进了 agent 的循环里——只不过审批人从”主管”变成了”正在用工具的你”,审批触发条件从”配置等级=高危”变成了”工具风险档=致命”。你当年设计”哪些操作要审批、谁有权批”,那套经验直接决定 agent 的 HITL 策略长什么样。你不是不会做安全,你是还没把”审批对象”从”人提交的工单”换成”模型生成的动作”。
我给你一段”危险动作人类确认”的示意,把 HITL 钉进循环:
# 危险动作人类确认(HITL)示意,非可运行
DANGEROUS = {"delete_file", "drop_table", "send_email", "deploy"}
def execute_tool_with_guard(tool_name, args):
# 第一道 + 第三道:致命工具不自动执行,先问人
if tool_name in DANGEROUS:
ok = ask_human(f"模型想执行【{tool_name}】参数={args}\n允许吗? (y/n)")
if not ok:
return "用户拒绝执行,请换一个安全的方案"
# 第二道:在沙箱里执行(示意:所有写操作限制在项目目录)
if not within_sandbox(args):
return "拒绝:该操作超出沙箱边界"
return tools[tool_name](**args) # 低风险或已确认的,才真执行
def agent_loop(goal, tools):
for step in range(50):
resp = model_chat(messages, tools=tools)
if resp.is_final_answer:
return resp.text
result = execute_tool_with_guard(resp.tool_call.name, resp.tool_call.args)
messages.append(...)老哥,你看 execute_tool_with_guard 里那两个 if:第一个是 HITL(致命动作先问人),第二个是沙箱(超边界直接拒)。这两道墙一立,模型再怎么幻觉、再怎么跑偏,也越不过”删库/外发”的红线——因为执行那一下,要么你点头,要么压根没权限。你写 Go 时 if !hasPermission(uid, op) { return ErrForbidden } 和这里的 if not ok / if not within_sandbox,是同一句”不授权就不执行”的纪律。区别只是:传统系统权限来自”用户身份”,agent 系统权限来自”动作风险档 + 人类实时确认”。
老哥,我再补一个你做安全一定会纠结的设计题:白名单还是黑名单? 新手爱用”黑名单”——列一堆”不许删库、不许发邮件”的禁止项。但这玩意儿永远漏:你列了 rm,它用 find ... -delete;你禁了 DROP,它用 TRUNCATE;你堵了 SQL,它用 shell。概率程序加黑名单,等于和会胡说的对手玩”猫鼠游戏”,你永远堵不全。正确姿势是白名单(默认拒绝)加 风险分级:agent 默认只能调”读/查/跑测试”这类白名单工具,其余一切默认不可达;要加新能力,显式评估风险档后开放。你后端做 API 网关的”默认拒绝、按需授权”,和这完全一致。白名单的代价是”起初能干的少”,但换来的是”跑偏也伤不到要害”——对概率程序,这比黑名单稳十倍。
再讲一个真实到肉疼的案例。有人演示”让 agent 帮我整理服务器文件”,给了一把 root shell 的权限,还心想”它应该不会乱来吧”。agent 在清理临时文件时,模型幻觉出”这个目录是缓存可以清”,实际上那是 /var/lib 下一个关键服务的数据目录,一句 rm -rf 把服务干瘫,恢复花了半天。根因就是三道墙全没立:没最小权限(给了 root)、没沙箱(在真机跑)、没 HITL(删前没问)。如果哪怕只立一道——“写/删类动作先问人”——这出就不会发生。我把这当反面教材反复讲,是因为它太典型:把概率程序当成确定性同事授权,是 agent 工程里最贵的错误。你给新人授权都知道”先给只读,观察一阵再放开”,对模型,这个纪律要更狠。
最后再给你一个把安全和体验捏到一起的提醒:护栏不是越厚越好,厚到碍事,用户就会绕开它。你若 HITL 问得太频繁、沙箱限制到 agent 啥也干不了,用户嫌烦,就会去”关掉确认""给 root”——结果护栏形同虚设,还不如少而准的护栏。所以设计时做”风险档映射”:低风险全自动(快)、中风险留痕(可追)、高风险才问(准),让护栏只挡真正要命的,其余放行保丝滑。这和你后端”安全要和可用性平衡,拦截太多业务就绕规则”是同一个权衡。好的 agent 安全,是”用户感觉不到、但真危险时一定拦得住”——润物细无声,才是高手。
【思考题】
- 最小权限、沙箱、HITL 这三道墙,分别防的是”模型犯的哪类错”?有没有哪道墙是”防不住”某种错的,需要另一道补?
- 如果 HITL 设计成”每一步都问人”,会发生什么?怎么判断”哪些动作该问、哪些该全自动”?
- 你后端做”权限 + 审批流”的经验,平移到 agent 的 HITL,最难适配的点是什么?(提示:审批人从人变成”正在用的你”,实时性要求不同)
【参考答案】
- 三道墙各防一类错:最小权限防”它根本不该有的能力被滥用”(比如你压根没给它生产库账号,它就不可能 DROP);沙箱防”它有了能力但搞砸了,爆炸半径被圈住”(在容器里删错文件,重启即恢复);HITL 防”它确实有权、且动作不可逆,但这次判断错了”(删库前你喊停)。没有哪道是万能的:最小权限防不住”你不得不给它、但它用错的”场景(比如必须写文件,它写错了位置)——这时靠沙箱限爆炸半径加 HITL 在写前确认;HITL 防不住”你疲劳了随手点 yes”——这时靠最小权限和沙箱兜底。所以必须纵深:单层都会被某种错击穿,三层叠起来才没有单点失效。这和你后端”认证/授权/隔离/审计”要齐全,是一个道理。
- “每一步都问”会摧毁体验且让人麻木——你点 50 次 yes 后必然开始无脑点,HITL 就形同虚设(警报疲劳)。判断标准就一条:按”动作的错误代价”分级。零/低代价(读文件、跑测试、搜索)全自动;中代价(改非关键文件、发内部请求)可全自动但留痕;高/不可逆代价(删、发对外、部署、改生产配置)才问人。本质是”把人的注意力留给真正会要命的动作”,和你后端”哪些变更要审批、哪些自助”的划分完全一致。再补一条:同一动作若”在沙箱里试”可先全自动,确认无误再问人”是否应用到真实环境”——把 HITL 从”每次”降到”边界处一次”。
- 最难适配的是实时性与连续性。后端审批流里,提交工单的人和执行审批的人是分离的、异步的,主管可以明天批;但 agent 的 HITL,审批人就是”此刻正盯着屏幕的你”,你必须秒级响应,否则整个循环卡住、体验断裂。这要求 HITL 设计成”非阻塞、可批量、可预设策略”:比如你提前设”删 /tmp 下的文件一律自动允许""删项目外文件一律拒绝”,把高频低风险决策前移成规则,只把真正意外的留给实时确认。另一个难点:后端审批对象是人写明的”操作加参数”,agent 的”动作”是模型生成的、可能措辞模糊,你得把它翻译成人能一眼看懂的”它将要做什么”再问——否则人无法判断。这恰是 Part 2”工具 schema 描述要写清楚”的回报:描述清楚,HITL 展示给用户时才不歧义。
小结:agent 的安全不能靠模型自觉,要靠三道工程护栏——最小权限(它能不能)、沙箱(搞砸了伤多深)、HITL(致命动作谁拍板),构成纵深防御。这全是后端”授权加隔离加审批流”心智从”防黑客”到”防内部概率程序犯浑”的平移。Part 2 的副作用安全,在此落为具体的墙。