嘲风登高探路观察和执行不是一回事模型能看见工具不等于它有权执行工具。[读者] 正把业务系统接进 Agent 的后端工程师 [痛点] 工具定义跑通了却迟迟不敢放开写操作 [现在读] 试运行第一天就撞上重复退款与越权查询 [读完] 能把工具调用拆成观察层和执行层并落地一组执行网关[旧方案] 本地函数塞进 tools 列表模型说调就调 | v [新需求] 退款、工单、发券这类有副作用的写操作要进 Agent | v [冲突] 模型只负责提议调用应用才负责授权与执行 | v [后果] 幂等缺失会重复退款鉴权缺失会越权操作我是老李在一个运营中台做后端。中台里已经沉淀了一批能力查订单、发起退款、建工单、给用户发券每一个背后都有稳定接口客服工作台和运营后台天天在调。今年客服团队提了个新需求这些能力也让 Agent 调一遍。客服在对话框里说一句模型自己去查单、判断能不能退、然后发起退款。听上去就是在接口外面包一层。我们照着 LangChain 的文档用 create_agent 加几个 tool 函数两天就出了演示。演示里模型很守规矩先 query_order 看可退金额再说金额最后 create_refund。于是我们把它放进了小范围试运行。试运行第一天客服在群里问这单怎么退了两遍工具定义明明跑通了为什么上线第一天就出现重复退款鉴权到底该交给模型自己判断还是留在应用侧死守如果这两条边界选错最坏的代价是什么01、故事现场是一个客服工作台Agent 接在对话框右侧跑在独立的服务里。我们的任务是让模型能安全调用三个有代表性的能力只读的查单、有副作用的退款、以及跨系统的建工单。最初的方案朴素得可爱把三个函数用 tool 装饰args_schema 用 Pydantic 模型定义然后把整个 tools 列表交给 create_agent。模型在循环里自己决定调谁、传什么参数工具返回值原样回到 messages 里它再决定下一步。变化从“谁做决定”开始。以前是客服点按钮前端把参数拼好发给后端后端校验、鉴权、落库现在是模型看着一句自然语言自己拼参数、自己决定要不要调。人的那层判断被替换成了一个概率模型。冲突也就在这里模型对工具的认知只有“看得见”——它看见名字、描述和参数定义。至于这个工具有什么权限、重复调用会怎样、超时之后结果到底成没成它一概不知道。而我们把这层“不知道”当成了“它会自己小心”。演示和试运行之间隔着一条线演示里没人关心失败试运行里每一次失败都要有人买单。演示一时顺上线两行泪观察先一步执行须三思02、问题旧方案失效得很彻底我们把工具当成普通函数谁调都行调几次都行。业务影响分三类。重复退款最直接对账对不平客服要手工冲正越权查询最隐蔽模型拿着服务账号的全量权限能查到不属于当前客服的客户订单脏工单最烦人参数没校验就落库事后清理比新建还难。技术表现也有迹可循。同一次对话里出现两个参数完全一致、id 不同的 tool_call参数类型不匹配时工具直接抛 Pydantic 的校验异常整段堆栈被塞回 messages模型看到堆栈后开始乱猜参数下游超时后工具抛异常Agent 循环把它当成“工具失败请重试”模型老老实实又调了一次权限不足时返回的错误信息里带着内部接口名等于把内部结构讲给了模型听。所以我们给下一版定了几条可验证的完成标准不达标就不放开写操作。第一同一个业务幂等键在时间窗内只产生一次副作用重复请求返回同一结果。第二模型只能看到调用者有权使用的工具执行侧再做一次校验两道都要有。第三每个工具调用有超时和错误分类可重试与不可重试分开可重试的才回灌模型。第四任意一次 tool_call 都能用 trace_id 还原出提议、鉴权、执行、返回四个阶段。副作用无小事一条也要算清边界写在代码不写在提示词03、原理要理解这次的坑只需要理解一件事一次 Tool Calling 其实是两段完全不同的过程中间有一条缝。第一段是观察。模型读工具列表、读对话、读工具返回值产出一个结构化的提议工具名、参数、调用 id。这一段发生在模型内部代价低、可重放、没有副作用。第二段是执行。应用把提议翻译成一次真实调用鉴权、限流、幂等、超时、落库都在这一段。这一段代价高、不可重放、有副作用。反直觉判断工具的描述文本不是接口文档它是提示词的一部分。文档是写给人看的人看完会去遵守描述是写给模型看的模型看完只会去模仿。你在描述里写“金额单位是分”模型大概率会照做但“大概率”不是约束。约束只能写在 args_schema 的类型和范围里以及执行侧的校验里。第二个反直觉判断给模型更多工具不会让它更强只会让它的选择空间变大、授权边界变模糊。工具列表本身就是权限的一部分可见即可调用这是最容易被忽略的一条。第三个判断关于成本不对称查单错了可以再查退款错了要走财务流程。观察便宜、执行贵所以两者的治理等级本来就不该一样。把只读工具和写工具放在同一个 tools 列表里、走同一套执行路径是把贵的和便宜的一起管结果通常是两边都没管好。看见不等于能做描述不等于约束便宜处可放开昂贵处须设闸04、架构[输入] 用户一句话 调用者身份tenant / user / trace | v [模块] Agent 循环create_agent 工具注册表按身份裁剪可见工具 | v [数据/状态] tool_call 提议name / args / id 幂等存储 授权表 | v [处理] 执行网关鉴权 → schema 校验 → 幂等判定 → 限时执行 → 错误分类 | v [输出] 结构化结果 / 分类错误 → 回灌模型或转人工这条链上有三个边界必须说清楚。第一个边界在模型和执行网关之间。模型只输出提议网关决定执不执行。这条线一旦模糊就会出现两类问题模型以为自己能退款其实没权限模型不知道下游超时了于是又退一次。第二个边界在网关和业务接口之间。网关负责通用治理鉴权、校验、幂等、超时、错误分类业务接口只负责业务规则。这样同一套治理逻辑可以复用到所有工具不用每个工具各写一遍。第三个边界在注册表和授权表之间。注册表是“系统有哪些能力”授权表是“这个人能用哪些能力”。可见工具列表必须是两者的交集而不是注册表的全量。收益很直接模型能看到的就一定是它能用的失败有分类、重复有拦截、超时有兜底。代价也很直接多了一层网关每次调用多一跳工具 schema 变成需要维护的资产少数需要多轮交互的复杂工具会被幂等键的设计卡住。适用条件同样明确只要工具有副作用就值得上网关纯只读、且数据敏感度低的工具可以先直连等出问题再收敛。反过来先直连再补网关的成本通常比一开始就上要高。三分模型意七分应用责闸门立在中间上下游皆安05、实战一次先跑通最小 V1不掺治理把“观察”这一段跑通问题留到后面几章。环境Python 3.11langchain 1.xcreate_agent 位于 langchain.agentslangchain-openaipydantic 2.x。具体版本号与导入路径需按当前官方文档核验。依赖pipinstall-Ulangchain langchain-openai pydantic配置设置 OPENAI_API_KEY 环境变量模型名按你账号下可用的模型替换。核心实现两个工具一个只读、一个有副作用参数用 Pydantic 定义。frompydanticimportBaseModel,Fieldfromlangchain_core.toolsimporttoolfromlangchain.agentsimportcreate_agentfromlangchain_openaiimportChatOpenAI ORDERS{SO2024001:{paid_fen:10000,refunded_fen:0,status:PAID}}REFUNDS[]classQueryOrderArgs(BaseModel):order_id:strField(description订单号形如 SO2024001)classRefundArgs(BaseModel):order_id:strField(description订单号形如 SO2024001)amount_fen:intField(ge1,description退款金额整数单位为分)reason:strField(max_length20,description退款原因20 字以内)tool(query_order,args_schemaQueryOrderArgs)defquery_order(order_id:str)-dict:查询订单状态与可退金额。只读操作可重复调用。oORDERS.get(order_id)ifoisNone:return{ok:False,code:ORDER_NOT_FOUND}return{ok:True,order_id:order_id,status:o[status],refundable_fen:o[paid_fen]-o[refunded_fen]}tool(create_refund,args_schemaRefundArgs)defcreate_refund(order_id:str,amount_fen:int,reason:str)-dict:对已支付订单发起退款。有副作用。oORDERS[order_id]ifamount_feno[paid_fen]-o[refunded_fen]:return{ok:False,code:AMOUNT_OUT_OF_RANGE}REFUNDS.append({order_id:order_id,amount_fen:amount_fen,reason:reason})o[refunded_fen]amount_fenreturn{ok:True,refund_id:RF%04d%len(REFUNDS)}agentcreate_agent(modelChatOpenAI(modelgpt-4.1-mini,temperature0),tools[query_order,create_refund],system_prompt你是客服助手。退款前必须先调用 query_order 确认可退金额。,)resagent.invoke({messages:[{role:user,content:客户要退 SO2024001 的全部金额原因是商品破损}]})forminres[messages]:print(type(m).__name__,getattr(m,tool_calls,None)orgetattr(m,content,))启动并请求后打印出来的东西大概长这样。未在当前环境实测以下为预期结果。示例输出AIMessage [{name: query_order, args: {order_id: SO2024001}, id: call_a1}] ToolMessage {ok: True, order_id: SO2024001, status: PAID, refundable_fen: 10000} AIMessage [{name: create_refund, args: {order_id: SO2024001, amount_fen: 10000, reason: 商品破损}, id: call_a2}] ToolMessage {ok: True, refund_id: RF0001} AIMessage 已为该订单退款 100.00 元退款单号 RF0001。验证REFUNDS 长度为 1说明这一次调用链闭合了。V1 到此算跑通。但留下三个问题没有鉴权任何调用者都能调到 create_refund没有幂等再问一遍就会再退一次没有超时下游卡住时模型只会等或者乱试。下一章就是这三个问题在真实环境里的样子。先让链路通再谈边界稳跑通不是终点是问题起点06、排查第一条链重复退款。现象客服反馈同一订单出现两笔退款REFUNDS 里两条记录金额完全一致。怀疑模型幻觉自己多调了一次。检查把 res[‘messages’] 全量打出来逐个看 AIMessage 的 tool_calls比对 name、args 与 id。证据两次 create_refund 的 args 完全相同但 tool_call id 不同两次调用之间夹着一条 ToolMessage内容是下游返回的 504。根因下游超时后工具把异常原样抛出Agent 循环把它当成“工具执行失败”按惯例让模型再试一次模型照着参数又调了一遍。工具本身没有任何幂等设计两次都真实落库。修复给写操作加幂等键取调用者身份加工具名加参数摘要在时间窗内直接回放首次结果同时把超时归类为“结果未知、不可重试”不允许模型自动重试。错误尝试我们第一反应是把 temperature 从 1 调到 0觉得这样模型就不会自己乱来。调完重复依旧。原因很简单重复不是采样随机导致的而是失败重试导致的temperature0 只会让模型更坚定地做出同一个错误决定。第二条链参数校验与越权。现象模型把 amount_fen 传成字符串 ‘100元’工具抛校验异常整段堆栈被塞回 messages模型接着改成 100000 分再试报 AMOUNT_OUT_OF_RANGE 之后开始反复道歉。怀疑schema 描述不清。检查检查 RefundArgs 的字段定义发现只有 description没有范围约束再看错误回灌内容里面带着内部堆栈和文件路径。证据同一个 trace 里出现三次 amount_fen 不同的调用一次字符串、一次 100000、一次 10000。根因约束只写在描述里没写在类型和范围里错误没有分类模型无法区分“参数错了可以改”和“权限不足改了也没用”。修复给字段加上取值范围与单位说明执行侧把所有异常收敛成五类——INVALID_ARGS、FORBIDDEN、TIMEOUT、CONFLICT、INTERNAL只有 INVALID_ARGS 与 CONFLICT 允许带简短原因回灌模型其余统一话术并转人工。越权是同一轮排查里发现的模型能查任意 order_id因为 create_agent 拿的是服务账号的全量权限。修复分两步工具列表按调用者身份裁剪执行侧再校验订单归属。现象常撒谎证据不说谎先看调用链再改一行码07、优化这一版的改动全部来自第 06 章的根因不做没证据的优化。根因一写操作没有幂等键。修改抽出执行网关所有工具调用走同一入口写操作按身份加工具名加参数摘要生成幂等键五分钟窗口内回放首次结果。根因二错误没有分类。修改网关把内部异常翻译成结构化的错误码附带 retryable 标志可重试的才回灌模型。根因三可见工具等于全量工具。修改注册表与授权表求交集生成当前调用者的可见工具列表。核心实现importjsonimporttimefromdataclassesimportdataclassclassToolError(Exception):def__init__(self,code,msg,retryable):super().__init__(msg)self.codecode self.msgmsg self.retryableretryabledataclassclassCallContext:tenant_id:struser_id:strtrace_id:strclassToolGateway:def__init__(self,registry,idem_store,authz,window_s300):self.registryregistry# name - (schema, handler)self.idemidem_store# key - {ts: float, result: dict}self.authzauthz# (user_id, name) - boolself.window_swindow_sdefvisible_tools(self,ctx):return[nforninself.registryifself.authz.allow(ctx.user_id,n)]defexecute(self,name,args,ctx,writeFalse):ifnamenotinself.registryornotself.authz.allow(ctx.user_id,name):raiseToolError(FORBIDDEN,该操作不可用,False)schema,handlerself.registry[name]try:parsedschema(**args)exceptException:raiseToolError(INVALID_ARGS,参数不合法请检查字段与单位,True)keyNoneifwrite:rawjson.dumps(parsed.model_dump(),sort_keysTrue,ensure_asciiFalse)key%s|%s|%s%(ctx.user_id,name,raw)hitself.idem.get(key)ifhitandtime.time()-hit[ts]self.window_s:returnhit[result]try:resulthandler(parsed,ctx)exceptTimeoutError:raiseToolError(TIMEOUT,下游超时结果未知请勿重复提交,False)exceptException:raiseToolError(INTERNAL,内部错误已转人工,False)ifwrite:self.idem.put(key,{ts:time.time(),result:result})returnresult工具侧只负责把结构化结果翻译回模型能读的文字不再自己抛异常tool(create_refund,args_schemaRefundArgs)defcreate_refund_tool(order_id:str,amount_fen:int,reason:str)-str:对已支付订单发起退款。有副作用。ctxcurrent_context()try:rgateway.execute(create_refund,{order_id:order_id,amount_fen:amount_fen,reason:reason},ctx,writeTrue)returnjson.dumps({ok:True,refund_id:r[refund_id]},ensure_asciiFalse)exceptToolErrorase:returnjson.dumps({ok:False,code:e.code,retryable:e.retryable,msg:e.msg},ensure_asciiFalse)新行为有三处变化。超时不再可重试模型收到的是“结果未知请勿重复提交”而不是一个可以再试一次的失败。幂等命中直接回放首次结果模型看到的是成功用户看到的也是一次。无权限时返回统一话术模型不会拿到内部接口名这类信息。验证方案把同一条用户输入用同一 trace_id 连发两次观察第二次是否直接命中幂等存储而不落库人为让下游睡眠超过超时阈值观察模型是否停止重试用一个无权限账号跑同一句输入观察可见工具列表是否缩短到只读。以上是验证方法本篇不编造执行数据。一键定重复一类定去留一表定可见一改定边界08、演进[同一输入] 用户说把 SO2024001 全退掉 | --[V1] 模型直接调 create_refund参数原样落库 | 代价无鉴权、无幂等、超时即重复 | --[V2] 模型调同一个工具但执行走网关 | 代价多一跳、schema 要维护、幂等键要想清楚 | [Trade-off] 得到可见即可授权、重复可拦截、失败可分类 失去一次简单调用的直连开销以及一部分模型自由发挥的空间 适用边界有副作用的写操作必须走网关只读且低敏的工具可以继续直连正确性上V1 把“参数对不对”和“该不该做”都赌在模型身上V2 把这两件事分别交给 schema 和授权表模型只保留“提议”这一职责。稳定性上V1 在下游抖动时副作用会放大V2 把不可重试的错误挡在循环之外。复杂度上V2 多了一个网关和一套幂等存储工具数量越多摊薄得越明显工具只有两三个时确实显得重。成本上V2 每次调用多一次查表换来的是对账成本下降这个交换在写操作上几乎总是划算的。适用范围上读多写少的系统可以只给写操作加网关不必一刀切。遗留问题也很清楚。第一幂等键的语义需要业务定部分退款算不算同一个键、跨天的重复请求要不要拦这些不是网关能替业务决定的。第二多实例部署时幂等存储得是共享的内存字典只能在单机演示里用。第三工具列表裁剪之后模型的选择空间变小某些需要试探性调用的场景会变差得用 system prompt 明确告诉它当前有哪些能力。旧路省一步新路多一闸取舍不在巧在问代价谁担09、洞见9.1 观察和执行是两种权限不该共用一套假设模型看见工具是上下文层面的可见性应用执行工具是系统层面的授权。这两件事的失败代价差了几个数量级。反直觉判断把工具描述写得更详细不会让执行更安全只会让模型更愿意调用。真正的安全线在 args_schema 和执行侧校验里描述只能提高“选对”的概率不能提高“做对”的概率。9.2 幂等是工具的契约不是模型的能力我们最初以为重复调用是模型“不够聪明”于是去调 temperature、去加“不要重复调用”的提示词全部无效。反直觉判断模型的每一次调用在它自己的视角里都是第一次因为它看不到上一轮的副作用是否已经落库。幂等必须由工具在执行侧保证模型只负责提议重复提议是它的正常行为不是它的缺陷。9.3 错误分类是给模型设计的产品接口把堆栈塞回 messages等于让模型去读一段它读不懂的日志。分类之后的错误码——可重试、不可重试、需人工——才是模型能消费的输入。这条经验可以推广凡是回灌给模型的内容都应当按“模型能据此做什么”来设计而不是按“人看着方便”来设计。9.4 MCP 把工具定义的所有权交了出去本地工具的 schema 和描述由自己维护改一行代码就生效MCP 工具的 schema 由远端服务提供远端一升级你的提示词和参数假设就可能同时失效。这不是缺点是把契约挪到了网络另一侧。接入 MCP 时至少要多做三件事对远端工具做一次 schema 快照并纳入变更监控把远端调用纳入同一套执行网关的超时与错误分类对远端工具做白名单只暴露当前调用者确实需要的那几个。具体接入方式与 transport 取值需按当前官方文档核验。权限在应用契约在工具错误要分类描述莫当约10、系统落地原来我们有一个能跑通演示的 Agent工具定义、参数 schema、create_agent 循环模型能查单也能退款。本篇新增了三样东西。一个执行网关把鉴权、校验、幂等、超时、错误分类收敛到一处一张工具注册表加授权表让可见工具列表等于可执行工具列表一套结构化错误协议让模型知道什么能重试、什么必须停。现在能做的是放开有副作用的写操作进入试运行因为重复有拦截、越权有拦截、超时有兜底出问题能用 trace_id 还原全过程。MCP 远端工具也能接进同一套治理里不用为它单独写一套逻辑。还缺的东西也明确。幂等键的业务语义还没定部分退款目前按参数摘要区分退款金额分两次提交会被当成两次不同操作。幂等存储还是单机实现多实例部署要换成共享存储。工具列表裁剪之后模型在复杂任务上的成功率有下降需要在 system prompt 里补一段“当前可用能力”的说明。下一步的演进顺序先把幂等存储换成共享实现再把 MCP 工具纳入注册表并加 schema 快照监控最后才考虑给模型更多工具。顺序不能反工具越多边界越难守。先立闸与表再谈广与深能力可渐增边界不可松11、小结Q1 → 因为工具只做了定义没做治理模型重复提议时执行侧没有幂等拦截超时还被当成可重试 Q2 → 不交给模型鉴权在应用侧可见工具按调用者裁剪执行侧二次校验 Q3 → 重复退款、越权查数、脏数据落库成本最后都在业务侧结算 状态 → 有了一套可复用的工具执行网关写操作可以安全进入试运行定义只是开始治理才算落地边界写进代码才敢放开执行12、作业12.1 理解题为什么把 temperature 调成 0 不能解决重复调用参考答案重复来自失败重试链路而不是采样随机。模型在超时后收到的信息是“工具失败”它在自己的上下文里没有“上次已经成功”的记忆因此重试是合理行为。temperature 只影响采样的随机性不影响循环控制逻辑。要解决重复必须在执行侧用幂等键拦截并把超时归类为不可重试。12.2 实战题给一个只读工具和一个写工具分别设计可见性与治理策略。参考答案只读工具可以按角色粗粒度放开例如所有客服都能查订单但执行侧仍要校验数据归属防止查别人的单。写工具按最小权限逐个授权并在注册表里标注是否为写操作写操作强制走网关、强制生成幂等键。两者的共同点是都要在 args_schema 上写清类型和范围不同点是写操作的错误必须分类且默认不可重试。12.3 排障题模型反复用一个不存在的订单号调用 query_order返回 ORDER_NOT_FOUND 后仍继续尝试怎么排查参考答案先看 messages 里模型的原始输入和工具返回确认订单号是用户给的还是模型自己编的如果是用户给的问题在输入解析检查 system prompt 是否要求先确认单号格式如果是模型编的检查工具返回是否带了足够的纠正信息例如“请向用户确认订单号”以及是否设置了最大循环次数。根因通常是错误返回只给了错误码没给下一步动作模型只能继续试。修复方式是让错误返回携带可执行建议并在 Agent 层设置循环上限。12.4 架构判断题有人提议把鉴权逻辑写进工具描述让模型自己判断有没有权限是否可行参考答案不可行。工具描述是提示词的一部分只能影响模型的选择倾向无法构成约束模型可以因为上下文误导而忽略描述。鉴权必须在执行侧且要在工具可见性上做第一道裁剪。描述里可以写“仅在有权限时使用”作为辅助提示但不能作为唯一防线。题在边界处答在证据里想清楚再改改完再验证13、思考这次事故的根子不在模型在我们把“模型能看见工具”当成了“模型能安全使用工具”。观察是上下文问题执行是系统问题两者的治理手段完全不同前者靠描述、示例和可见性裁剪后者靠 schema、鉴权、幂等和错误分类。给模型加能力很容易加边界很难而边界才是能上生产的那部分。下一次再往 tools 列表里加东西时先问三个问题这个操作有没有副作用失败重试会不会放大后果调用者有没有权限。三个问题里只要有一个答案不确定就该走网关。看见是上下文执行是系统边界先画好能力才敢放