Part 2 工具机制:给 LLM 一只手
墨叔开场:老哥,Part 1 咱们把”agent = 模型 + 循环”这张底图钉死了。从这一 Part 开始,咱们具体拆”循环是怎么把模型的手接上的”。工具,就是那只手。这一 Part 五节,从”手是什么”一路讲到”手不能乱伸”——你每读完一节,先逼自己答章末的思考题,再翻参考答案,这是咱们约定好的学法。
2.1 工具的本质:给 LLM 一只”手”
老哥,我先抛个你可能从没认真想过的怪问题:模型既然”那么聪明”,能写出看起来像样的代码,那我们干嘛不直接让它把代码写出来、在你的机器上跑掉、把事儿一手办了?干嘛非得绕一道弯子——让它”输出一段’我要调哪个函数、参数是什么’的意图”,再让你的循环去真跑?这弯子绕得,是不是多此一举?
你先别急着答,你先猜一下为什么。
——因为”让模型直接写代码并执行”,等于把你的服务器、你的文件系统、你的数据库、你的网络出口,全权交给了一个概率系统。墨叔在 Part 1 跟你交底过:模型是”下一个 token 预测器”,它”像”而不”对”。让它直接 os.remove、直接 subprocess.run、直接拼 SQL 执行,等于把煤气灶的开关塞给一个会一本正经胡说八道的实习生,还告诉他”随便用,出事算你的”。这画面你品一品,后背发凉不?
所以 agent 这整套设计,从根上就立着一条铁律:决策与执行分离。模型只负责”产出调用意图”——一段结构化的、形如”我想调用 search_files,参数是 {‘pattern’: ‘*.go’}“的描述;而你写的循环负责”校验这段意图合不合法、然后在受控环境里真去执行那个函数、再把结果读回来喂给模型”。模型从头到尾,碰不到你的磁盘、碰不到你的库。
我给你一个狠一点的类比,落到你最熟的场子。模型像个坐在前厅点单的顾客,工具是你后厨的厨师和灶台。顾客(模型)能说”我要一份辣子鸡、不要香菜”,但他绝不该自己冲进后厨去点火、去切鸡、去掌勺——一来他没厨艺证(没权限),二来他万一”觉得”往锅里倒一整瓶油,后厨就炸了(副作用失控),三来真出了事没人知道是谁点的(不可审计)。Function Calling 这套机制,本质就是把”顾客点单”标准化了:后厨提前把菜单(工具清单)递给顾客,每张菜单写清”菜名、这道菜是什么、要备哪些料、料是什么规格”。顾客照着菜单点,后厨照单做,做完把”上菜结果”端回去。整条链路里,顾客永远碰不到灶台。
老哥,你品品这个分离的分量。它解决的不是”模型能不能写代码”这种技术问题,而是”怎么让一个不可全信的概率系统,安全地驱动真实世界”这种工程问题。决策交给模型(它擅长读人话、凑方案),执行留给代码(你擅长保证必然正确、可控、可审计)。这道三八线,是后面所有安全设计的总根,你记死了。
那”工具清单”到底长什么样?我给你看最小的结构。每个工具就三样东西:一个名字(name)、一句人话描述(description,给模型看的,告诉它这工具能干嘛、啥时候该用)、一套参数定义(parameters,用 JSON Schema 描述类型、是否必填、枚举值)。注意,这套定义不是给你程序员看的文档,是给模型”读”的——模型靠读 description 来决定”现在该点这道菜不”,靠读 parameters 来知道”这道菜要备什么料、料长啥样”。这点在 2.2 我会展开讲,因为 description 写得好坏,直接决定调用准不准。
我给你一个最小的 Python function-calling 示意,你就看它有多朴素:
# 工具清单:就是一份"菜单"(示意,非可运行)
TOOLS = [
{
"name": "search_files",
"description": "在代码库里按文件名模式搜索文件,返回匹配的路径列表。当你需要定位某个文件时使用。",
"parameters": {
"type": "object",
"properties": {
"pattern": {"type": "string", "description": "glob 模式,如 '*.go'"},
"top_dir": {"type": "string", "description": "从哪个目录开始搜,默认当前目录"}
},
"required": ["pattern"]
}
}
]
# 真实函数实现:模型碰不到,只有循环能调
TOOL_IMPLS = {
"search_files": lambda pattern, top_dir=".": _run_grep(pattern, top_dir)
}
# 循环里:模型输出"调用意图",代码来真执行
def agent_step(messages):
resp = model_chat(messages, tools=TOOLS) # 模型可能返回: tool_call={name:"search_files", args:{pattern:"*.go"}}
if resp.tool_call:
fn = TOOL_IMPLS[resp.tool_call.name] # 按名字取到真实函数(厨师)
result = fn(**resp.tool_call.args) # 代码执行,模型碰不到文件系统
return f"[{resp.tool_call.name}] 返回: {result}"
return resp.text # 模型说"我直接回答"老哥,你盯住 TOOL_IMPLS[resp.tool_call.name] 这行——这就是”按名字取函数然后执行”。模型从头到尾只产出了一个字符串 search_files 和一个参数包,它从来没碰过你的磁盘。真正 grep、真正读文件、真正返回结果的,是 TOOL_IMPLS 里你写死的函数。决策(点哪个菜)在模型,执行(做这道菜)在代码。这就是 Function Calling 的全部魔法,没有更神秘的东西了。你别被”工具调用”这词唬住,它底层就是个”按名字查函数表 + 调用”。
现在给你落到你本行的连接,降降陌生感。你写五年 Go 后端,这套东西你早用过,只是换了个马甲:
- 工具清单(TOOLS)像你 service 层的接口契约:每个工具就是
type Tool interface { Name() string; Run(args Args) (Result, error) },而 description 就是你写在接口上面的 godoc 注释。 - 模型”按上下文选工具”像你 handler 里的路由分发 / 依赖注入:
container.Get(toolName).Run(args)——只不过”选哪个”这个决策,传统是你switch写死的,agent 是模型”读着纸条猜”的。 - 最关键的一点:你早就在做”决策与执行分离”。你的 HTTP 服务,请求进来(决策源是客户端),handler 解析后调具体函数(执行在你代码里),你绝不会让客户端直接
exec你的服务器命令——你用路由、用权限中间件把”客户能决定什么”框死。agent 只是把”决策源”从”人类点按钮”换成”模型读上下文”,执行环境那套防御,你本来就懂。区别只在:以前决策源是确定的人,现在决策源是概率的模型,所以你的”框”得加厚一点——这正是 2.3、2.4 要讲的。
给你一段真实经验压舱。早期很多人做 agent,图省事走了”让模型写代码、丢进一个 exec 里跑”的邪路(有点像 code interpreter 的思路)。结果翻车翻得很有共性:模型能写出”删除当前目录下所有 .py 文件”的脚本(因为它”觉得”在清理);能写出 while True: pass 的死循环把容器 CPU 打满;能写出读 /etc/passwd 的探测脚本。这些不是偶发,是概率系统的必然副产物——它不知道什么该碰、什么不该碰,也没有”生产禁忌”这根弦。后来大家收敛出的正道,就是我上面这套:工具白名单 + 受控执行 + 结果回流。模型永远在一个”阉割过”的能力集里表达意图,真正的威力(和真正的危险)都在你代码手里。换句话说,手的”肌肉”在代码,模型只是”想伸哪只手”的念头。
还有一点值得你现在就建立直觉:工具的”粒度”是门手艺,后面 2.5 会专门讲。但我先点一句——工具太粗(一个 do_everything 工具),模型就不会用;工具太碎(一百个细工具),模型挑花眼、上下文也被清单撑爆。手不是越多越好,是”刚好够用、边界清晰”最好。这跟你说”微服务拆多细”是同一个哲学困境,你品。
老哥,你可能会追问一句:那像 code interpreter 那样,让模型在隔离沙箱里生成代码、自己跑,不也挺好?为什么我非得把每个能力都固化成工具?这是个好问题,墨叔把这条光谱给你画清楚。沙箱里跑代码,优点是灵活——模型能拼出你没预见的组合,处理你没专门写工具的边角需求;缺点是不可控、不可审计、不可预测,而且沙箱一旦配置有缝,就是破防点。工具模式(tool use)的精髓,是用”能力白名单”换”可控性”:你只给模型你精心设计并审过的那几只手,它再怎么”觉得”,也只能在你划好的能力集里表达意图。生产系统里,尤其是碰你业务的 CRUD,可控性远比灵活性值钱——你宁可 agent “偶尔老实说它做不到”,也不愿它”用没预见的方式把事搞砸”。所以墨叔的默认建议是:生产 agent 走工具模式;把”生成代码执行”严格限制在可信沙箱、且只用于低风险探索场景。这跟你后端”生产禁 eval、禁动态 SQL 拼接”是同一个洁癖——能力开放面越小,攻击面越小,这是铁律。
再补一刀你一定会用的判断尺。当你犹豫”这个功能要不要做成工具给 agent”,问自己三个问题:一、这动作错了,最坏后果是什么——可逆的小错,还是删库级?二、这动作模型”瞎调”的概率高吗——高频刚需且描述清晰就低,模糊就高;三、这动作需要”必然正确”还是”差不多就行”(前者必须代码保证,模型只决策)。三个答案直接决定这功能是”裸给、加护栏给、还是不给”。这根尺,后面 2.4、2.5 你会反复用。
【思考题】
- “让模型直接写代码执行”和”让模型输出调用意图、循环去执行”,两种方案的本质区别在哪?为什么前者在真实生产里几乎必翻车?
- 模型在 Function Calling 里输出的”调用意图”,在 agent 架构里到底由谁负责”真的去做”?如果这一步交给模型自己,会发生什么?
- 你后端里”决策与执行分离”最像的一个例子是什么?和 agent 的工具机制对照一下,找出”唯一多出来的那层复杂度”是什么。
【参考答案】
- 本质区别在于执行权的归属与护栏的有无。前者把执行权直接交给模型(一个会幻觉、会算错、无状态、不知道生产禁忌的概率系统),它生成的代码一旦被 exec,就是真实的、不可逆的副作用,没有校验、没有白名单、没有回滚;后者把执行权牢牢捏在代码手里,模型只产出”我希望调 X 传 Y”的结构化意图,循环先 schema 校验、再在受控环境执行、再读回结果。前者翻车不是因为模型”偶尔笨”,而是因为任何概率系统的输出一旦获得无约束的执行权,它的出错面就等于你整个系统的攻击面——它不需要经常错,错一次就够你删库跑路。所以”手”必须是代码给的,不能是模型自己长的。
- “真的去做”永远是循环(你的代码)的责任,绝不能交给模型。如果这一步交给模型,等于让一个没有文件系统/数据库/网络权限、且输出不可信的远程推理服务去直接操作你的环境——它本身没权限是小事,更致命的是:它没法”读回结果形成闭环”(它生成一个动作就结束了一轮,不知道动作后果),它也没有护栏意识(不知道”删库”是禁忌)。所以架构铁律是:模型提议,代码执行,代码读回。这条线一错,agent 就从”受控助手”变成”裸奔的 rm -rf”。
- 最像的是你后端里的命令/查询分发(CQRS 或 handler 路由):客户端发来”我要查订单”,服务端按路由找到
OrderQueryHandler执行,客户端绝不直接碰数据库。对照 agent:路由表=工具清单,handler=TOOL_IMPLS,客户端=模型。唯一多出来的复杂度是决策源从”确定的人/配置”变成”概率的模型”。这层多出来的复杂度,逼你做三件事传统路由不用做的:一是意图的合法性校验(模型可能产出非法 JSON/缺字段),二是危险动作的二次确认(模型可能”想”删库),三是结果回流进上下文让模型继续(模型无状态,必须靠你喂回事实)。换句话说,工具机制的 90% 就是你熟悉的分发,剩下 10% 是”为概率决策源补的护栏”——而这 10% 正是 2.3、2.4 的全部内容。
小结:Function Calling 的核心是”决策与执行分离”——模型只产出调用意图,循环负责校验与受控执行。工具清单就是一份”菜单”(name/description/parameters),模型像点单的顾客、代码像掌勺的厨师。你后端的路由分发早已是这套思维,只是决策源从确定的人变成了概率的模型,需要补上校验、确认、回流这层护栏。
2.2 Tool Schema 设计:名字/描述/参数的取舍
老哥,我抛个你大概率踩过的场景:同一个工具,description 你随手写一句”获取用户信息”,模型十次有三次调错;你咬牙把描述改成”按姓名或邮箱模糊搜索、返回匹配的用户列表”,调对率蹭蹭往上涨。你可能会纳闷:模型不是”读得懂人话”吗,怎么被一句话描述拿捏得死死的?
你先猜一下,为什么描述这么要命?
——因为模型选工具,唯一依据就是你给它的那几句 description。它不像人类,人类看到两个长得很像的工具,还能凭经验、凭常识瞎蒙一个;模型没有”常识兜底”,它完全、彻底、字面地依赖你写的那段文字来做决定。你写得含糊,它就随机选;你写得边界清楚,它就选得准。description 不是文档,是模型做决策的唯一输入。这一点,是 schema 设计的总纲,你先刻进脑子里。
我给你拆”描述该写什么”。这里有个关键取舍:写”能做什么”,不如写”什么时候该用”。很多人(包括早期的我)写 description,习惯写能力——“本工具用于查询订单”。但这对模型决策帮助有限,因为”查询订单”和”查询用户""查询日志”听起来都像”查东西”,模型在”该调哪个”上还是分不清。真正该写的是触发条件和使用场景:“当你需要根据订单号获取单个订单的详情、且用户已提供确切订单号时使用;若只有用户名,请用 search_orders。“——你看,后半句直接告诉模型”别调我,去调那个”,调用准确率立刻拉开。
落一个狠类比。工具 description 像餐厅菜单上的”推荐语”,而不是”食材表”。写”能做什么”像写”本店提供炒饭”(顾客看了还是不知道自己该不该点);写”什么时候用”像写”赶时间、一个人吃、不想点菜的,点这个最合适,上得最快”(顾客(模型)一下就做对决策)。模型没来过你这家餐厅,它全靠菜单上的推荐语下单——你写清”什么场景点我”,它才不会点错。
参数(parameters)这一层,有三个设计要点,我挨个讲:
第一,类型要严。能用 string/integer/boolean/enum 就别用 object 兜底,能用具体结构就别用 string 装一坨自由文本。类型越严,模型产出的参数越不容易跑偏,你的循环校验也越省力。这跟你写 Go 时”能用强类型就别用 interface{}”是同一个洁癖——类型即护栏。
第二,必填(required)要克制。很多人一激动把所有字段都标必填,结果模型经常漏一两个,导致 schema 校验直接打回、整轮作废。required 只放”真没有就完全没法执行”的字段,其余给默认值、标 optional。模型是概率的,你逼它每次都凑齐八个必填,等于逼一个会走神的同事每次交表都零差错——不现实。
第三,enum 是神器。凡是参数取值是个有限集合(状态、类型、动作),一定要用 enum 列死。比如”排序方向”只能是 asc/desc,就别让模型自由填字符串。enum 把”自由文本”变成”受控选项”,一举两得:模型出错率骤降,注入风险(模型填个奇怪值甚至一段指令)也被堵死。你后端做”状态机”时也一样——用枚举不用字符串,本质是同一套防御。
给你落到你本行的连接。你写 Go,天天在写”契约”:接口的 godoc、OpenAPI 的 schema、protobuf 的字段注释、数据库表的 DDL 注释。那些契约是给人看的、给代码生成器看的、给前端兄弟看的。但 agent 的工具契约,是给**一个只会字面理解、不会脑补的”读者”**看的。你的 API 文档写含糊,前端兄弟能靠经验脑补补上;模型不能脑补,它只会按字面最像的那句去选、去填。所以你写工具 description 时,得当成”写给一个极其较真、零常识、但语文很好的实习生”——把”什么情况用我、别和隔壁那个搞混”写透。
我再给你一段真实踩坑。曾有个项目里两个工具紧挨着:search_user(按条件搜,返回列表)和 get_user(按 ID 取单个)。最初两个 description 都写”获取用户信息”,模型随机挑,调错率接近一半——明明要取单个,它常去搜一列表回来,白白浪费一轮。后来把描述改成”按姓名/邮箱模糊搜索,返回可能匹配的多个用户,用于你只知道模糊条件时”和”按精确用户 ID 取单个用户,用于你已拿到确切 ID 时”,调错率从 50% 掉到 99% 以下。你看,区分度不在功能,在”何时用”。这一改,就是 schema 设计的全部精髓的缩影。
还有个反直觉的点:description 不是越长越好。模型读工具清单时,清单本身也占上下文窗口(Part 1 讲过,那是张有限的桌子)。你给每个工具写三百字小作文,十个工具就是三千字,桌子一下被菜单占满,反而稀释了模型对真正任务的注意力。所以 description 的功力是”用最少的字,写出最大的区分度”——像写一条精准的 git commit message,不写废话,只写”为什么、何时用”。这跟你好不容易学会的”注释要写 why 不写 what”完全同构。
我给你看一个”坏 schema”和”好 schema”的对照示意,你感受下区别:
# 坏:描述写能力、参数宽松、无 enum、必填堆满
bad_tool = {
"name": "query_data",
"description": "查询数据。", # 太短,没说何时用
"parameters": {
"type": "object",
"properties": {
"sql": {"type": "string"}, # 自由文本,模型可能拼错/注入
"mode": {"type": "string"}, # 没 enum,模型可能填 "fast"/"quick"
"limit": {"type": "integer"}
},
"required": ["sql", "mode", "limit"] # 全必填,易缺字段被打回
}
}
# 好:描述写场景、参数收紧、enum 兜底、必填克制
good_tool = {
"name": "query_orders",
"description": "按订单号查询单个订单详情。仅当你已拿到确切订单号、且只需这一单的信息时使用;若只有用户名请用 search_orders。",
"parameters": {
"type": "object",
"properties": {
"order_id": {"type": "string", "description": "形如 'OD12345' 的订单号"},
"include_items": {"type": "boolean", "description": "是否一并返回商品行,默认 false"}
},
"required": ["order_id"] # 只卡真正必须的
}
}老哥,你对比这两段,好与坏不在代码量,在”有没有替模型把决策成本降下来”。坏的那个,模型拿到手要想”sql 怎么拼、mode 填啥、limit 不给行不行”——全是它不擅长的概率决策;好的那个,模型只需想”order_id 是啥”,其余都替它兜好了。schema 设计的全部心法,就是把模型的决策成本压到最低、把歧义空间压到零。
老哥,schema 设计还有几个你一定会踩的细坑,我一口气点出来,省得你重蹈覆辙。
第一,名字要”动词_名词”且全局好辨。别叫 get(get 什么?)、别叫 process(process 啥?)。叫 get_order、search_orders、draft_reminder。模型选工具时,名字是第一眼信号,模糊的名字等于没给信号。这跟你 Go 里”函数名要表意”同一纪律,只是这里表意的对象是模型——它不认识你的业务缩写,全靠字面判断。
第二,description 里写”决策信号”,别写”实现细节”。常见错误是把 description 写成接口文档:“本工具接受 order_id,调用 OrderService.Query…”——模型不关心你的 service 叫啥,它只关心”我该不该调”。description 该写的,是触发条件和与相邻工具的边界,实现细节留给 parameters 里各字段的 description(给模型填参时用)就够了。把”代码逻辑”塞进 description,既占上下文又干扰决策,两头不讨好。
第三,参数的 description 写”怎么填”而非”是什么”。比如 order_id 别只写”订单ID”,写”形如 OD12345 的订单号,从订单详情页 URL 末尾获取”——把”去哪找这个值”也告诉模型,它填错率骤降。你写 Go 结构体 tag 的注释也一样,好注释告诉调用方”传什么、怎么传、从哪来”,而不是只重复字段名。
第四,让相邻工具的”何时用”互相排斥。十个工具里若三个描述都提”订单”,模型会在三者间摇摆。解法不是堆更多字,而是划清非此即彼的边界:A 说”你已有确切 ID 时用我”,B 说”你只有模糊条件时用我”,C 说”你要批量时用我”——边界不重叠,模型就不选错。描述的艺术是划清”非此即彼”,不是罗列功能。
第五,工具也会”版本演进”。你改了工具参数或语义,旧的描述可能误导模型,所以改工具要同步改 description;废弃的工具要从清单移除(或标 deprecated 让模型知道”别用了”)。工具清单不是静态配置,是随你系统演进而演进的契约——和你维护 OpenAPI 文档是同一份责任心,改了接口就得改文档,不然调用方(这里是模型)就蒙。
第六,别让工具参数逼模型做它不擅长的”算”。比如某工具要”最近 30 天的订单”,别让模型自己拼日期字符串(它算错概率高,Part 1 讲过它”像”不”对”),而是给个相对描述(如 range 取 last_30_days 的 enum),让代码转成真实日期。这又回到 Part 1 的总纲——让模型决定”要什么”,让代码保证”换算必然正确”。凡是”必然正确”的填值,尽量做成 enum 或相对选项,别甩给模型填空算。这条原则,贯穿你后面所有工具设计。
【思考题】
- 为什么模型选工具”完全依赖 description”,而不能像人一样靠常识兜底?这对你写 description 提出了什么要求?
- description 写”能做什么”和写”什么时候用”,对模型决策的影响差在哪?你能举一个你自己系统里两个容易混淆的接口为例,试着写两句”何时用”的描述吗?
- enum 在工具参数里除了”降低模型出错率”,还顺手解决了什么工程问题?(提示:想想安全和校验)
【参考答案】
- 模型是”下一个 token 预测器”,它没有你意义上的”常识”——它只是在训练语料里见过大量”描述→选择”的模式,靠字面相似度去匹配。两个 description 都写”获取用户信息”,它在语料里找不到足够强的信号区分谁该用,于是概率性地乱选。不像人,人脑子里有”订单号=精确查单个”这种先验,能兜底。所以你写 description 的要求是:把人类会用的”先验/场景判断”显式写进去,让模型不需要常识也能做对决策。换句话说,description 是在替模型补它缺失的常识。
- “能做什么”只告诉模型”我能干这个”,但没告诉它”你和隔壁那个干得差不多时该挑谁”,模型仍在歧义里;“什么时候用”直接给了决策边界,等于替模型把”选 A 还是选 B”的判断题变成了”条件匹配就选我”的匹配题,难度骤降。举例你系统里两个接口:
/api/user/profile(取当前登录用户资料)和/api/user/{id}(按 ID 取任意用户)。坏描述都写”获取用户资料”;好描述应写”获取当前已登录用户的个人资料,无需传 ID” 和 “按管理员提供的确切用户 ID 获取任意用户资料,用于后台客服场景”。一句点清”谁该用我”,模型就不会把客服查询套到自己的资料接口上。 - enum 顺手解决了两个工程大问题:一是注入/越权风险——自由文本参数里模型可能塞进一段恶意指令或奇怪值(比如 mode 填
"drop table"的变形),enum 把取值锁死在白名单,从输入源掐断;二是校验成本——你的循环拿到参数后,只需if value not in ENUM就能判定合法,不用写一堆正则或语义解析。这跟你后端”状态字段用枚举不用字符串”是同一套防御哲学:把无限输入空间收敛成有限可信集合,系统就可控了。所以凡参数取值有限,无条件上 enum。
小结:description 是模型选工具的唯一依据,必须写”什么时候用”而非只写”能做什么”,把人类常识显式补进描述。参数设计三要点——类型要严、required 要克制、取值有限的务必上 enum。schema 的全部心法,是把模型的决策成本和歧义空间压到最低,同时顺手用 enum 把注入风险掐死在源头。
2.3 工具调用的失败与重试:错误处理是 agent 的免疫系统
老哥,你写五年 Go,错误处理是你肌肉记忆里的东西:if err != nil 写得到处都是,超时、熔断、重试你门儿清。那我问你一个尖锐的:agent 里”工具调用”也会失败,而且失败的方式比你后端野得多——你猜,agent 的工具失败,跟你平时 db.Query 失败,最不一样的地方在哪?
你先想三秒。
——最不一样的是:有一种失败,你从来没在后端见过,叫”模型返回了一段根本不是合法调用的东西”。你 db.Query 再怎么崩,崩的也是”执行”阶段,参数至少是结构体、是确定性的;但 agent 里,模型可能返回一段非法 JSON、可能漏了必填字段、可能把参数类型搞错(比如该传数字它传了字符串)、甚至瞎编了一个你工具清单里根本不存在的工具名。这种”意图本身就不合法”的失败,是你后端没有的物种——因为你的调用方是人类写的确定性代码,而 agent 的调用方是概率模型。
所以 agent 的错误处理,比后端多了一整层:先校验”意图合不合法”,再谈”执行成不成功”。我把这整套称为 agent 的”免疫系统”——它要识别三类病原,分别对症下药。
第一类病原:parse 失败(意图非法)。模型吐回来的 tool_call,要么 JSON 残缺,要么字段对不上 schema。怎么办?你的循环不能拿着这段垃圾直接去 TOOL_IMPLS[name] 查——查不到或类型错都会崩。正确做法是:在真正执行前,先跑一道结构化校验(用 JSON Schema 校验库,或你自己写的类型检查)。校验不过,别崩溃,而是把”你刚才的调用非法,原因是 XXX,请按 schema 重来”这条信息,作为 feedback 塞回上下文,让模型看到自己的错误、自己纠正。这招在 Part 1 的 Agent Loop 代码里你已经见过雏形:except 把错误读回纸条。这里的关键是——parse 失败不是终点,是”教模型”的机会。
第二类病原:工具运行时报错(执行失败)。意图合法,但真去跑时炸了——文件不存在、数据库连接超时、外部 API 返回 500。这跟你后端熟悉的错误一模一样。处理方式也一模一样:把错误原文(截断后)读回上下文,让模型决定下一步。模型看到”文件不存在”,可能换个路径再试,可能改策略。这里有个精髓:你不要把异常吞掉只回个 “failed”,那等于递给模型一张写”出错了”的废纸条,它啥也干不了;你要回”调用 search_files 失败:路径 ./src 不存在”,模型才有事实可据。这又是 Part 1 讲的”基于环境的纠错”——硬事实把模型的瞎想拉回地面。
第三类病原:超时(卡死)。模型调了个工具,那工具因为网络或死循环迟迟不返回。你后端有 context.WithTimeout,agent 这边一样要给每个工具调用套超时。超时就当”执行失败”处理,把”工具 X 超时未返回”读回上下文,让模型换路或放弃。没有超时兜底的 agent,一个卡死的工具就能把整条循环挂死,token 烧着、CPU 转着、事儿没办成——这是生产事故的标准剧本。
我给你一个类比,落到你熟透的东西。agent 的错误处理像人体的免疫系统:parse 失败是”认出这不是自己人”(先天免疫,挡在门外);运行时报错是”进了体内但干活出错,记录下来下次避开”(适应性免疫,靠反馈学习);超时是”某个器官罢工太久,直接切断它保命”(器官衰竭的兜底)。三者合起来,agent 才不会因为一次小错就整体崩盘,而是”带病工作、边错边学”。这恰恰是你后端”防御性编程”的升级版——你原来防御的是”网络/空指针”,现在额外防御”模型这个决策源本身会产出垃圾意图”。
给你落到你本行的硬连接。你后端的错误处理三板斧——校验(validation)、重试(retry)、熔断(circuit breaker)——在 agent 里一个都不少,只是形态变了:
- 校验:你原来校验”用户传来的参数合不合法”;agent 里多校验一层”模型产出的调用意图合不合法”,因为模型比用户更不可信。
- 重试:你后端重试要小心幂等——同一个写请求重试两次可能下两单。agent 里更要命,因为模型可能”觉得”该重试一个非幂等的删除。所以重试前必须确认工具幂等(2.4 细讲)。
- 熔断:你后端某个依赖挂了,熔断避免雪崩;agent 里某个工具反复失败(比如外部 API 挂了),也得熔断,别让模型在死胡同里反复调、反复烧 token。你可以记一个”该工具连续失败 N 次就暂时禁用”的状态。
但你要注意一个反直觉的差异:后端重试,是你代码确定性地”再发一次”;agent 重试,往往不是简单重发,而是把错误读给模型、让模型”换个想法”再发。前者是机械重试,后者是”带反馈的Retry”——模型看到错误,可能改参数、可能改工具、可能改策略。这才是 agent 错误处理比普通后端高明的地方:错误不是被”吞了”,是被”学了”。
给你一段真实经验,压一压。我们早期做一个文件操作 agent,模型经常”想”调一个不存在的工具名(比如把 read_file 记成 open_file),循环一查 TOOL_IMPLS 没这键就 panic,整个会话炸了。后来加了两道:一是校验阶段发现 name 不在清单,就回”没有这个工具,可用工具有:read_file / write_file / list_dir,请重选”;二是即便走到执行,也用 TOOL_IMPLS.get(name, fallback) 兜底,绝不 panic。加了这两道后,那类崩溃归零,而且模型被提示”可用工具有哪些”后,几乎立刻就改对了。你看,把”失败”变成”教学信号”,agent 反而更稳——这跟人带新人一模一样:新人调错命令,你别骂他笨让他重来,你告诉他”正确命令是这个”,他下次就会了。
再看超时的真坑。模型有一次调了个 run_shell 去编译一个巨项目,编译卡了二十分钟,我们的循环没设超时,干等,用户以为程序死了,强杀,上下文全丢。从那以后,所有工具调用统一套 timeout(seconds),默认 30 秒,超时返回”执行超时,请换更小粒度的操作或换工具”,模型通常就改成”先编译单个包”。超时不是冷酷的切断,是给模型一个”此路不通”的信号,让它换道。
# agent 错误处理的"免疫系统"示意(非可运行)
def call_tool_safely(tool_call, impls, timeout_s=30):
# 第一道:parse / schema 校验(认出非法意图)
if tool_call.name not in impls:
return f"错误:没有名为 '{tool_call.name}' 的工具。可用:{list(impls)}"
err = validate_schema(impls[tool_call.name].schema, tool_call.args)
if err:
return f"错误:参数不合法 —— {err}。请按 schema 修正后重试。"
# 第二+三道:执行 + 超时(执行可能炸、可能卡)
try:
with time_limit(timeout_s): # 超时兜底
result = impls[tool_call.name](**tool_call.args)
return f"[{tool_call.name}] 返回: {result}"
except TimeoutError:
return f"错误:{tool_call.name} 超过 {timeout_s}s 未返回,请换更小粒度或换工具。"
except Exception as e:
return f"错误:{tool_call.name} 执行失败 —— {type(e).__name__}: {e}" # 原文读回,供模型自救老哥,你看这段代码,三道病原三种 return,但每一条 return 都是”把事实读回上下文”,没有一条是悄悄吞掉。这正是免疫系统的精髓:错误要可见、可学、可恢复。你后端 if err != nil { return err } 是把错往上传;agent 这里是把错”翻译”成模型能读的自然语言,往上下文里传。同一个”错要往上冒”的原则,只是终点从”调用方代码”变成了”模型的下一轮推理”。
老哥,错误处理还有个你后端也头疼、但 agent 里更夸张的坑,叫”重试风暴”。你后端重试,最多把一个请求发三五次;agent 重试是模型带着新想法再来一轮,每一轮都要重新调一次模型(烧 token)、可能再触发新工具、再烧 token。如果某工具一直失败、模型又”固执地”觉得该用它,你的 token 账单会指数级放大——一次失败变十次调用,十次变百次,钱烧得你肉疼。所以 agent 里必须给重试设预算:单工具连续失败超 K 次就熔断(暂时禁用并提示”此路不通”),单轮总步数有上限(Part 1 的 max_steps),单任务总成本有上限。这叫”错误预算”思维,和你后端”限流+熔断”同构,只是度量从”QPS”变成了”token 与步数”。
还要把错误分”可重试”和”不可重试”。网络超时、外部 500 是可重试的(等症候可能自愈);参数非法、权限拒绝是不可重试的——重发一百次还是同一个错,纯烧钱。所以校验层发现”意图非法”时,别进重试队列,直接把”你写错了、改这”读回给模型让它换想法;只有”执行期瞬态错误”才进重试。分清两类,agent 既稳又不浪费,这是免疫系统成熟的标志。
给你一个真金白银的教训:我们有次一个 run_query 工具因数据库主从延迟偶发超时,模型”觉得”是参数问题,每轮换不同参数重试,十五轮烧掉平时一百轮的 token,最后还没成。后来加了”同一工具 3 次瞬态失败即熔断、提示换工具或放弃”,类似事故 token 成本直接砍到十分之一。你看,给概率决策源设”犯错额度”,是生产 agent 的必选项,不是优化项——它决定你的账单是线性还是爆炸。
【思考题】
- agent 工具失败里,哪一种是你后端”从来没见过”的物种?它为什么存在?你的循环该怎么兜?
- 后端重试和 agent 重试,表面都是”再试一次”,底层动机有什么不同?为什么 agent 重试常常不是简单重发?
- 如果工具报错时,你的循环只回一句 “failed” 而不是错误原文,对模型的下一轮决策有什么后果?这违背了 Part 1 讲的哪个机制?
【参考答案】
- “意图本身非法”这一类(模型产出非法 JSON、漏字段、编造不存在的工具名)是后端没有的——因为后端的调用方是确定性代码,参数至少是合法结构体;而 agent 的调用方是概率模型,它产出的”调用意图”可能从根上就不合法。它存在的原因是:模型在”采样”输出,没有硬约束保证它吐出的一定符合 schema。循环兜法是:执行前先跑结构化校验(schema 校验),不过就把”你非法、原因是 XXX、可用工具有哪些”作为 feedback 塞回上下文,让模型自我纠正。校验这道关,是 agent 比普通后端多出来的”先天免疫”。
- 后端重试是机械重试——代码确定性地”把同一个请求再发一次”,动机是”网络抖动可能只是暂时的”;agent 重试常常是带反馈的学习型重试——把错误读给模型,模型看到事实后可能改参数、改工具、甚至改整体策略再发,动机是”模型的决策可能因错误信息而错,给它正确反馈它能换个对的想法”。所以 agent 的重试对象不是”同一个动作”,而是”同一个目标的下一轮更聪明的动作”。这也解释了为什么 agent 里重试要配合”把错误原文回流”,而不是静默重发——静默重发只会让模型重复同一个错误动作。
- 如果只回 “failed”,模型拿到的是一张没有信息的废纸条,它既不知道为什么错、也不知道怎么改,下一轮只能凭空再猜,大概率重复同一个错误,甚至陷入”调→失败→再调”的死循环,白白烧 token。这直接违背了 Part 1 讲的**“基于环境的纠错(grounding)”**——那条机制的命根子是”把真实世界的硬事实读回上下文,让事实纠正模型的瞎想”。只回 “failed” 等于抽掉了事实,模型失去了被纠正的锚点,循环就从”自修正引擎”退化成”瞎转圈机器”。所以工具报错必须回流原文(截断后),这是闭环成立的底线。
小结:agent 的错误处理是套”免疫系统”,要识别三类病原——意图非法(parse 失败)、执行报错、超时——分别用 schema 校验、错误原文回流、超时切断来兜。核心原则是”错误要可见、可学、可恢复”,每一条错误都翻译回上下文让模型自我纠正。这把你后端”校验/重试/熔断”三板斧升级成了”为概率决策源补的防御”,而重试从机械重发变成了带反馈的学习。
2.4 副作用与安全:为什么不能随便让 agent 删文件
老哥,这一节咱们聊点让你睡不着的——副作用。你写后端,最怕的就是”不该发生的写”:误删生产数据、重复下发、重复扣款。这些一旦真发生,就是事故,pager 响到你手机发烫。那我问你:如果 agent 有了”手”(2.1 讲的工具),而它又是个会幻觉、会算错的概率系统,你猜,最危险的那只手,伸向哪类动作时你最该发抖?
你先说。
——是不可逆的、有外部后果的写动作:删文件、发请求(尤其是外发邮件/消息)、动钱(支付/转账)、改生产数据。因为这些动作”做了就收不回”——rm 掉的库、发出去的邮件、扣掉的钱,不像内存里一个变量赋值能回滚。而模型,恰恰最可能在”觉得该这么做”时,一本正经地去做这些。所以这一节的总原则只有一句:凡是不可逆的副作用,绝不让模型直接执行,必须隔着一道道护栏。
我给你先讲三个护栏的基石,挨个拆。
第一块基石:幂等(idempotency)。 老哥你太熟了——你后端做支付、做下单,幂等键(idempotency-key)是基本功:同一个请求发两次,效果只发生一次,不会下两单、扣两次钱。在 agent 里,幂等比你后端还命根,因为模型天然会重试、会”觉得”该再试一次。如果一个”发送通知”的工具不幂等,模型在”是不是没发成功”的错觉下重试三次,用户就收到三遍同样的通知,体验崩了;更糟的是”删除”如果不幂等+带确认,重试可能删掉不该删的。所以你暴露给 agent 的每一个有副作用的工具,第一件事就是问自己:这工具重试 N 次,后果是一样的吗? 不一样,就别直接暴露,得包一层幂等(比如删除前先查”是否已删”、发送前用去重键)。
第二块基石:危险动作确认(human-in-the-loop, HITL)。 删库、外发、支付这类,最稳的做法不是”模型想删就删”,而是”模型只能请求,人(或规则)来拍板”。这叫人类确认回路。具体到工程,你有几种粒度:最严的是每次都暂停等人确认(适合删生产、转账);松一点的是白名单内自动、白名单外确认(比如只允许删 /tmp 下的、只允许发给内部域名);再松是审计日志 + 事后复核(适合低风险写)。核心思想:把”不可逆动作的开关”从模型手里收回到”确定的规则或确定的人”手里。模型是提议者,不是裁决者。
第三块基石:沙箱隔离(sandbox)。 即使模型调的工具都是你写的,你也不能让它在你真实生产环境里裸奔。沙箱就是给 agent 划一块”练功房”:文件系统只给它看特定目录,网络只放特定出口,数据库只连特定(最好是只读/测试)实例。它在这个房间里怎么折腾,都伤不到真身。这跟你后端”测试环境/生产环境隔离”是同一哲学,只是 agent 因为”决策源不可信”,隔离的必要性从”好习惯”升级成”保命绳”。
我给你一个狠类比,收一下。给 agent 暴露工具,像给一个刚来的、很能干但会梦游的实习生发门禁卡。你不会一入职就把机房、金库、对外发件箱的卡都给他(那是灾难);你会:先只给办公区卡(沙箱),让他能干活但碰不到核心;涉及花钱发件的,必须你签字(确认);所有他经手的操作都留痕(审计)。等他证明自己不梦游了,再慢慢加卡。模型的”梦游”不是比喻——它就是概率系统,必然有时会”懵”,所以门禁必须分级、必须留痕、必须有你签字的环节。
落到你本行的硬连接。这三块基石,你五年 Go 生涯里早就在用,只是名字不同:
- 幂等 → 你下单接口的
idempotency-key、你消息队列的”至少一次投递要配去重”、你补偿事务的设计。一样的东西,搬到 agent 工具上。 - 确认/白名单 → 你后端的”权限中间件""RBAC”:不同角色能调不同接口。agent 工具层就是给模型做一个极简 RBAC——哪些工具它随时能调(只读查询),哪些要确认(危险写)。
- 沙箱 → 你早就懂”生产库不能让开发直连""k8s 命名空间隔离”。agent 的沙箱就是把”模型”当成一个最不可信的租户,丢进最小权限的命名空间。
给你一段真实经验,压舱。我们做过一个”帮我把项目里所有 TODO 注释删掉”的需求,模型很乖地调了 delete_lines,但因为它”觉得”匹配范围该更大,连带删了一段真逻辑,CI 红了才发现。教训:凡是”删除/修改”类工具,后来我们都改成默认”只标出、不真删”——工具先返回”将删除以下 N 处,确认吗?“,人回 “yes” 才真执行;或者直接做成 dry_run 模式,先给 diff 给人看。从那以后,再没因为模型的”觉得”丢过代码。这条经验你记死:让 agent 做破坏性动作,默认走”预览 + 确认”,而不是”执行 + 后悔”。
还有个你容易忽略的点:“外发”比”删除”更阴险。删除至少你(或 CI)能发现;外发一封骂客户的邮件、外发一条到公网的 webhook,发出去就收不回,还可能泄露数据。所以凡是带”出口”的工具(发邮件、调外部 API 写数据、上传),白名单要卡死目标地址,日志要全程留痕,最好默认禁止发往陌生域名。模型不知道”这个域名是公网还是内网”,它只知道”用户让我发”。这种”它不知道自己在外面”的动作,最该套护栏。
我给你看一个”把危险动作封装成安全带工具”的示意,你感受下那层包裹:
# 危险动作封装:模型只有"请求权",代码掌握"执行权 + 护栏"(示意,非可运行)
ALLOWED_DELETE_ROOTS = ["/tmp/agent_sandbox", "./scratch"] # 白名单:只能删这俩目录下的
def safe_delete(path: str, confirm_token: str = None) -> str:
# 护栏1:路径必须在白名单内(沙箱边界)
if not any(path.startswith(root) for root in ALLOWED_DELETE_ROOTS):
return f"拒绝:{path} 不在允许删除的范围内(仅限 {ALLOWED_DELETE_ROOTS})。"
# 护栏2:幂等——已不存在就直接返回成功,不重复副作用
if not os.path.exists(path):
return "无需操作:目标已不存在。"
# 护栏3:危险动作需确认令牌(HITL)
if confirm_token != "USER_APPROVED":
return f"待确认:即将删除 {path}。请向用户展示此信息并取得确认后,带 confirm_token='USER_APPROVED' 重试。"
# 护栏4:全程审计日志
audit_log(f"DELETE {path} by agent, approved={confirm_token}")
os.remove(path)
return f"已删除 {path}。"老哥,你看这个 safe_delete:白名单卡边界、幂等防重复、确认令牌卡执行、审计留痕。模型调它时,第一次只会拿到”待确认”回执——它根本没有能力真的删,删的权力在 confirm_token 背后那个人/规则手里。这就是”模型只有请求权”的具象:它能说”我想删”,但删不删、在哪删、删完记不记,全是代码和人的地盘。你后端做”软删除 + 权限校验 + 操作日志”其实就是这套,只是这里把”权限校验”收紧到了”概率决策源”这个最不可信的角色上。
老哥,安全这层我再补两刀,都是血泪换来的。
第一刀:护栏要”纵深防御”,别赌单点。白名单漏了还有确认,确认疲劳点错了还有审计,审计之外还有沙箱。单一护栏总有被绕过或被忽略的时刻——比如人确认时走神点错。设计原则是”任何一层失守,下一层还能兜”:即便误确认了删除,沙箱限定它只能删 /tmp 下,生产库安然无恙;即便沙箱配置错了,审计也记下谁删的、能追责能恢复。你后端安全也是这套——WAF、鉴权、审计、备份层层叠,没有”一个开关管所有”的侥幸。给 agent 的护栏同样要叠,不能赌某一道万无一失。
第二刀:把”预览 / dry-run”做成默认姿态,不是例外。你或许想”只有删除才需确认吧”。错。凡是”改了难撤回”的写,都该默认 dry-run:工具先返”将执行 XXX,影响 N 行,diff 如下”,人(或规则)确认才真落地。这把”模型的提议”和”世界的改变”之间,永远插一道人眼。哪怕你觉得”低风险”的”改订单状态”,也曾因模型”觉得该改”把已解决单改回处理中(2.5 案例)。原则:默认预览,例外执行,把”执行”当成需要理由的特殊动作。这和你写危险 SQL 前先 SELECT 看影响行数,是一个肌肉记忆。
还有个你容易低估的:agent 的沙箱不只是”它能做什么”,还包括”它能看到什么”。若 agent 能读生产库明文密钥、读客户 PII,那”它把这些信息写进工具返回、再进上下文、再被泄露”就是风险。所以沙箱要双向——既限”能力”也限”数据”。最小权限,对能力和数据都生效。这也呼应 2.3:模型看到的越少,瞎指挥的空间越小,你的上下文桌子(Part 1 讲的有限工作台)也越干净。
【思考题】
- 为什么”外发”(发邮件/调外部 API)往往比”删除”更阴险、更需要护栏?你系统里有没有这类”出口”动作?
- 幂等在 agent 工具里为什么比在你普通后端里”更命根”?模型的什么特性逼你必须做幂等?
- “模型只有请求权”这句话,落到你后端权限设计里,对应的是什么概念?它如何防止”删库跑路”?
【参考答案】
- “外发”比”删除”阴险,因为删除通常可被你或 CI 发现并止损,而外发一旦出口就收不回、还可能泄露数据。删了代码,CI 红、能靠 git 恢复;发出去一封错邮件、一条到公网的 webhook,接收方已经收到,无法撤回,且可能把内部数据带出去。所以凡带”出口”的工具,白名单必须卡死目标地址(只允许内部/已知域名),日志必须全程留痕,最好默认禁止发往陌生域名。你系统里的”出口”动作比如:发通知(短信/邮件/push)、调用第三方写接口、上传文件到 OSS、往消息队列发消息——这些都应视为”不可逆外发”,套最严的护栏。
- 因为模型天然会重试、会”觉得该再试一次”——这是概率系统的行为特性,不是 bug。你普通后端里重试是你代码确定性控制的,你清楚知道哪次该重发;但 agent 里模型可能在”是不是没成功”的错觉下,对同一个”发送/删除”动作连发三次。如果工具不幂等,用户就收三遍通知、库被删三次(或删到不想要的)。所以 agent 工具的第一问必须是”重试 N 次后果一样吗”——不一样就包幂等层(去重键、先查是否已删)。模型的”爱重试”特性,把幂等从”好习惯”变成了”保命绳”。
- “模型只有请求权”对应你后端的权限最小化(least privilege)+ RBAC + 操作审计:模型(最不可信的”用户”)只被授予”发起请求”的角色,真正的”执行 + 裁决”权限在代码和确定的人手里。它防止删库跑路的方式是:删除类工具默认不真执行,而是返回”待确认”并展示给人,人确认后才带令牌执行;且路径卡死在白名单、操作全程留痕。模型永远拿不到”无确认直接删生产”的开关,就像你绝不把
DROP DATABASE的权限开放给一个匿名接口。开关不在不可信的决策源手里,事故面就被锁死了。
小结:不可逆副作用(删/外发/支付)是 agent 最危险的手,必须用三块基石兜——幂等(防重试重复副作用)、人类确认(危险动作模型只请求不裁决)、沙箱隔离(最小权限练功房)。把危险动作封装成”带白名单、带幂等、带确认、带审计”的工具,模型只有请求权,执行权与裁决权在代码和人手里,这才是防删库跑路的根本。
2.5 真实案例:把一个 CRUD 系统暴露成 agent 工具
老哥,前面四节把”手是什么、怎么设计、怎么兜错、怎么管安全”讲透了。这一节咱们落一个你天天在干的活:把你手上的一个 CRUD 系统,暴露成 agent 能调的工具。这是你未来最可能真实遇到的场景——老板说”让 AI 也能查咱们的订单”,你接活。那问题来了:你那一堆接口,哪些该暴露给 agent,哪些碰都不能碰?这中间的”暴露面”该怎么划?
你先自己盘一盘:假设你有个订单服务,接口有”查订单、搜订单、改订单状态、删订单、导出报表、发催付短信”。你觉得哪几个该给 agent,哪几个绝对不能?
想清楚没?我直接给你结论框架:暴露面划在”只读 + 可逆草稿”这一侧,绝对不碰”破坏性写 + 外发 + 无审计写”那一侧。具体拆:
该暴露的(绿灯):
- 只读查询:按 ID 查订单、按条件搜订单列表。只读不改,错了顶多返回不对,不伤数据。而且是 agent 最高频的需求——“帮我找一下张三上个月的大额订单”——天然适合。
- 生成草稿:让 agent 生成”一封催付邮件草稿""一条更新说明”,但只返回文本,不真发。草稿是可逆的、不落库的,人审完再发。这把模型的”语言流畅”长处用上,又不放它碰出口。
- 受限的预览类写:比如”把订单标记为高优先级”这种低风险、可逆、有审计的写,可在白名单+确认下开放。
绝对不能暴露的(红灯):
- 直接 DELETE:让 agent 直接
DELETE FROM orders WHERE ...是作死。模型”觉得”该清理,可能一把删光。必须走 2.4 讲的”预览 + 确认 + 白名单”,绝不直接暴露删除工具。 - 无审计的写:凡是改了生产数据却不留痕的写接口,别给 agent。你连”谁改的、改前改后”都不知道,出了事没法查。agent 的写必须强制带审计字段(operator=agent、trace_id)。
- 外发动作:发短信、发邮件、调外部写 API。见 2.4,出口动作默认禁,白名单+确认才开。
- 批量/高危管理操作:批量改状态、导全量数据到外部、改权限。这些本来人就该小心,更别交给概率模型。
我给你一个狠类比,收这局的。把 CRUD 系统暴露给 agent,像把公司前台开放给一个外包临时工:你能让他”查通讯录、引路、填一张访客表”(只读+草稿),但你绝不会把”删员工档案、给供应商打款、用公司名义发公函”的权限给他(破坏性+外发)。临时工(模型)能干的是”信息类、可逆类”的活;碰钱、碰档案、碰对外的,必须你(人或规则)签字。暴露面的设计,本质就是”给临时工定权限清单”。
落到你本行的硬连接,这其实就是你最熟的API 分级 + 权限模型:你早就把接口分成”内部/外部""只读/读写""高危/普通”,早就有 RBAC 控制谁能调哪个。现在只不过把”调用方”从”前端/其他服务”换成”agent(模型)“,而模型是所有调用方里最不可信的那个——所以它该拿到的是你权限矩阵里”最低那一档”的能力:只读为主,写要确认,高危全禁。你原来的权限设计直接平移,只是把 agent 当成”零信任租户”对待。
给你一段真实经验,压舱。我们真做过把内部工单系统暴露给 agent 的项目。第一版我们图省事,把”更新工单状态""删除工单""发客户通知”都直接包成工具丢给模型。结果一周内:模型把三个工单状态从”已解决”改回”处理中”(因为它”觉得”问题没根治),误发两封措辞奇怪的客户通知,还”想”删一个它”觉得”是重复工单的条目(被 2.4 的确认护栏挡下,没真删)。复盘后我们做了三件事:一是把所有写工具改成默认返回 diff/预览,人确认才执行;二是删除类直接从工具清单移除,改成”标出疑似重复”由人处理;三是外发工具加白名单(只发内部测试号)和审计。改完之后,agent 从”事故源”变成了”真能帮上忙的查询+草稿助手”——它每天帮人查工单、生成回复草稿,人只需最后点一下发送。这才是 agent 该有的姿态:在它可靠的区域(只读、草稿)放手用,在它不可靠的区域(写、外发、删)收住,让人兜底。
还有个设计权衡你一定会撞:暴露面太大 vs 太小。暴露太多(一百个细工具全给),模型挑花眼、上下文被清单撑爆、调错率升;暴露太少(只给一个 do_anything),模型根本不会用、还容易产出危险意图。经验值是:暴露”刚好够覆盖常见任务”的 5~15 个边界清晰、命名无歧义的工具,每个都按 2.2 写好 description,危险动作统一走”预览+确认”包装。这跟你说”微服务拆多细、API 设计粒度”是同一个老难题——没有银弹,靠你对自己业务的任务分布去调。
我给你看一个”订单系统暴露成工具”的清单示意,你对照着感受红绿灯:
# 订单系统 → agent 工具清单(示意,非可运行)。注意:每个工具都是"带护栏的封装",不是裸接口。
AGENT_TOOLS = [
# 绿灯:只读,直接给
{"name": "get_order", "description": "按确切订单号查单个订单详情。仅当你已有订单号时用;按用户名搜请用 search_orders。",
"impl": get_order, "risk": "read"},
{"name": "search_orders", "description": "按用户名/时间/金额范围搜订单列表,只读。",
"impl": search_orders, "risk": "read"},
# 黄灯:生成草稿,返回文本不落地
{"name": "draft_reminder", "description": "生成一封催付提醒邮件的草稿文本,仅返回文字,不发送。发送需人确认。",
"impl": draft_reminder, "risk": "draft"},
# 红灯包装:写/删/外发,全部走确认 + 白名单 + 审计
{"name": "update_order_status", "description": "修改订单状态。高危写操作,调用后返回变更预览,需人确认才生效,全程审计。",
"impl": guarded_update_status, "risk": "write_confirm"},
# 注意:delete_order 根本不出现在清单里,改成下面这个"只读标出"
{"name": "flag_suspect_duplicates", "description": "只读扫描,标出疑似重复的订单 ID 列表返回给人,不删除任何数据。",
"impl": flag_duplicates, "risk": "read"},
# send_sms 默认在白名单外,需显式开启且限内部号
# {"name": "send_sms", ...} # 默认不暴露
]老哥,你看这个清单的用心:get_order/search_orders 裸给(只读不怕);draft_reminder 只产文本(草稿可逆);update_order_status 虽是写,但套了 guarded_update_status(预览+确认+审计);delete_order 直接从清单消失,换成只读的”标出疑似重复”交由人处理;send_sms 默认不暴露。这就是”暴露面”的全部艺术:不是把所有接口都包成工具,而是按风险分级,绿的放、黄的加护栏放、红的收起来或换成安全的替代。
最后点一句你未来会懂的:暴露面的设计,决定了 agent 的”天花板”和”事故下限”。暴露太少,agent 啥也干不了(天花板低);暴露太多不加护栏,agent 天天闯祸(事故下限高)。好的设计是让 agent 在安全的区域里尽量能干、在危险的边界上必须等人——这跟你带实习生、和你做权限系统,是同一个智慧:信任要给,但给在看得见的地方。
老哥,暴露面这事儿,我再给你补一个做工程一定会需要的视角:可观测性。你把 CRUD 系统暴露给 agent 后,最怕的不是它”做错”,是它”做错了你却不知道它干了啥”。所以每个 agent 触发的工具调用,都必须落一条结构化日志:谁(session id)、调了啥工具、传了啥参数、返回啥、耗时多少、人是确认还是拒绝。这条日志既是审计(出事能查),也是你反推暴露面该调哪的依据——你发现 update_order_status 被模型乱调三十次,说明这工具要么描述不清、要么本不该开放,回去收。暴露面不是一次定死,是”上线→看日志→调护栏→再看”迭代出来的。这跟你做线上服务的”监控驱动优化”同构:没有观测,就没有迭代,agent 也一样。
还有句收尾的狠话:把系统暴露成 agent 工具,会倒逼你把系统接口设计得更干净。因为模型比人更”较真”地照契约办事,你那些”参数含义模糊、副作用隐式、命名随意”的接口,一给 agent 就暴露问题。所以很多团队做 agent 化后,反而顺手把 API 重构了一遍——工具清单,成了你系统接口质量的”照妖镜”。这是意外红利:为了让 AI 用得动,你得先把给人用的接口也写清楚。
最后落到你心坎上:暴露面设计的终极目标,不是”让 agent 什么都能干”,而是”让 agent 在它可靠的区域里尽量能干、在不可靠的边界上必须等人”。这句话你记到 Part 8 造自己的 Claude Code 时还会用——手的边界,就是 agent 的边界。
【思考题】
- 如果你要把自己的订单系统暴露给 agent,你会把”查订单""改状态""删订单""发催付短信”分别划成红/黄/绿哪一级?各自该套什么护栏?
- 为什么”删除”类工具,好的做法常常是直接”从工具清单移除、改成只读标出”?这比”给删除工具加确认”好在哪?
- 暴露面”太大”和”太小”各有什么代价?你觉得自己系统的工具数该控制在什么量级?依据是什么?
【参考答案】
- 分级与护栏:
- 查订单(绿):只读,直接暴露,无需特殊护栏(错了顶多返回不对,不伤数据),按 2.2 写好 description 防调错即可。
- 改状态(黄):低风险写但不可逆(改了状态就变),套”预览+确认+审计”封装,模型调后返回变更 diff,人确认才生效,且强制带
operator=agent, trace_id审计字段。 - 删订单(红):破坏性、不可恢复,绝不直接暴露删除工具;改成只读”标出疑似该删的订单”返回给人,由人执行删除。即便要留删除能力,也必须白名单路径+确认令牌+审计(见 2.4 的 safe_delete)。
- 发催付短信(红/外发):带出口,默认不暴露;若开放则限白名单(仅内部测试号/特定客户)、全程审计、最好人确认。外发一旦出口收不回,是最该收死的动作。
- “直接移除删除工具、改成只读标出”比”给删除加确认”更稳,因为:确认环节仍依赖人或规则在回路里,而”标出”把模型的角色彻底限定在它可靠的’信息/分析’区——它只需”找出疑似重复”,执行删除这个不可逆动作完全在人手里,连”模型发起了删除请求”这个风险点都消除了。加确认的做法,模型仍可能频繁发起删除请求、频繁打断人确认,体验差且仍有确认疲劳导致误点的可能;移除则是从根上不让模型碰这个动作。原则:能让模型’分析出来给人’的,就别让它’请求执行’。
- 暴露面太大的代价:模型挑花眼、调错率升、上下文被工具清单撑爆、危险面扩大(更多红灯动作被暴露);太小的代价:agent 啥也干不了,价值归零(天花板低)。经验量级是 5~15 个边界清晰、命名无歧义的工具,依据是”覆盖你系统里最高频的那几类 agent 任务”——你该先统计’如果 AI 能帮我,我最常让它查/做的是什么’,按这个任务分布去定工具集,而不是把接口表无脑翻译一遍。工具数由’任务分布’定,不由’接口总数’定,这是暴露面设计的核心判断尺。
小结:把 CRUD 系统暴露给 agent,暴露面划在”只读 + 可逆草稿”这一侧,绝对不碰”破坏性写 + 外发 + 无审计写”。红灯动作要么套预览+确认+审计,要么直接从清单移除、改成只读标出交由人执行。暴露面大小是个权衡——绿区放手用、红区收住等人,信任要给在看得见的地方,这和你做权限系统、带实习生是同一套智慧。
墨叔收口:老哥,Part 2 这五节,从”手是什么”讲到”手不能乱伸”。你回头看,核心就一句话——工具机制的本质,是把’不可信的概率决策源’安全地接到’你可信的执行环境’上。名字、描述、参数,是你替模型补的常识;校验、重试、超时,是你给它的免疫系统;幂等、确认、沙箱,是你给它的最低权限门禁。这每一块,你后端那套防御性编程功力都能平移,只是决策源从”确定的人”换成了”概率的模型”,护栏得加厚。下一 Part 咱们聊”规划与分解”——手有了,怎么让 agent 不至于东一榔头西一棒槌,而是真能把一件复杂事办成。