首页 / 资讯中心 / 文章详情

绕开Tool Calling:结构化通用Agent的工程化实践

绕开Tool Calling:结构化通用Agent的工程化实践 ★ FEATURED ARTICLE
1. 为什么我要绕开 Tool Calling 做 Agent1.1 一个被过度神化的机制过去一年里只要聊到 Agent 开发几乎所有人第一反应都是 Tool Calling。模型厂商把它包装成 Agent 的标配能力各种框架也把它当成默认路径。我一开始也是这么干的接上工具定义、让模型自己决定调哪个函数、拿到返回值再继续推理看起来非常优雅。但真到了生产环境问题就来了。Tool Calling 本质上依赖模型厂商在推理层面对特定格式的支持不同厂商的实现细节差异极大。同一个工具定义在 A 模型上跑得好好的换到 B 模型就开始乱调参数甚至干脆不调。更麻烦的是一旦你用的是本地部署的开源模型或者某些不支持原生 Tool Calling 的推理接口整条链路直接断掉。我踩过最狠的一次坑是给一个客户做内部知识库 Agent。开发阶段用的是某家 APITool Calling 稳得不行。结果客户要求私有化部署换成了本地模型工具调用成功率从 95% 掉到 40% 都不到。那段时间我天天对着日志怀疑人生最后发现根本不是 Prompt 写得不好而是模型对工具调用格式的理解压根就不一致。1.2 结构化通用 Agent 的核心思路绕开 Tool Calling不代表放弃工具能力。我的做法是把调用工具这件事从模型的特殊能力降级成普通的文本生成任务。模型不需要知道什么是 function call它只需要按照我规定的格式输出一段结构化的文本我在外面解析这段文本执行对应的操作再把结果塞回上下文。这个思路听起来很朴素但它带来的好处是实打实的。第一模型无关性。只要模型能稳定输出 JSON 或者特定标记格式的文本就能跑 Agent不管它是 GPT、Claude 还是本地跑的 Qwen、Llama。第二可控性。所有工具调用的解析、校验、执行都在我自己的代码里出问题能定位到具体哪一步而不是黑盒里猜。第三调试友好。模型的每一次输出都是纯文本我可以直接打印出来看不用去翻那些嵌套好几层的 tool_calls 结构。这里说的结构化指的是模型输出有明确的格式约束而不是让模型自由发挥。格式约束越清晰解析越稳定Agent 的可靠性就越高。1.3 这套方案适合谁如果你正在做 Agent 相关的项目但遇到以下任何一种情况这套方案值得你认真看一下用的是不支持原生 Tool Calling 的模型需要在多个模型之间切换对 Agent 的执行过程要求可观测、可干预或者单纯觉得 Tool Calling 的黑盒感太强想自己掌控全流程。反过来如果你只是做个 Demo用官方 API 加 Tool Calling 五分钟就能跑通那没必要折腾。这套方案的价值在于工程可控性而不是开发速度。我个人的经验是Demo 阶段怎么快怎么来一旦要上生产、要换模型、要排查问题结构化方案的优势就会立刻体现出来。2. 结构化 Agent 的整体架构拆解2.1 核心循环ReAct 的简化与改造ReAct 模式大家应该都不陌生Thought、Action、Observation 三步循环。标准 ReAct 里Action 那一步通常就是 Tool Calling。我把它改成了纯文本协议模型在 Action 阶段输出一段固定格式的文本比如以特定标记开头后面跟 JSON 参数。外层代码用正则或者字符串切分把这段内容抠出来解析成结构化数据再路由到对应的工具函数。整个循环的骨架大概是这样系统 Prompt 定义好输出格式和可用工具列表用户输入进来模型生成一段包含思考和行动指令的文本解析器提取行动指令执行工具把结果作为 Observation 拼回对话历史再次调用模型直到模型输出最终答案或者达到最大轮次。这个改造的关键点在于Prompt 里必须把格式规则写得极其明确。我一般会给出两到三个完整的输出示例覆盖需要调用工具和直接回答两种情况。示例比规则管用这一点在结构化输出上体现得特别明显。2.2 为什么不用 JSON Mode有人会问既然要结构化为什么不直接用 JSON Mode 或者 Structured Output 功能。我的答案是能用当然好但不能依赖。JSON Mode 同样是模型厂商的特性换模型就可能失效。而且 JSON Mode 通常只保证输出是合法 JSON不保证字段符合你的业务语义。我的做法是双保险Prompt 里要求模型输出特定标记包裹的 JSON外层解析器先做格式校验再做字段校验任何一步失败就触发重试或者降级处理。这样即使模型偶尔抽风整个系统也不会直接崩掉。实测下来加了校验和重试之后工具调用的有效率达到 98% 以上比裸用 Tool Calling 还稳。2.3 上下文管理的基本策略Agent 跑多轮之后上下文会迅速膨胀。我的策略是分层管理系统 Prompt 和工具定义放在最前面这部分是固定的中间是对话历史和工具调用记录这部分会增长最后是当前轮次的指令。对于中间部分我做了滑动窗口加摘要的处理。超过一定轮次或者 token 数之后把早期的工具调用记录压缩成一句话摘要只保留关键结论。这样既控制了 token 消耗又不会丢失重要信息。具体阈值我一般设在上下文窗口的 60% 左右留出足够的空间给模型生成。上下文管理是 Agent 工程里最容易被忽视、但影响最大的环节。我见过太多项目因为上下文爆炸导致模型开始胡言乱语最后归咎于模型不行其实是工程没做好。3. 核心细节Prompt 设计与解析器实现3.1 系统 Prompt 的写法系统 Prompt 是这套方案的地基。我一般把它分成四个部分角色定义、工具清单、输出格式规范、行为约束。角色定义要简短一句话说清楚这个 Agent 是干什么的。工具清单用列表形式每个工具写清楚名称、用途、参数说明。输出格式规范是重点我会明确告诉模型需要调用工具时输出以特定标记开头的内容后面跟 JSON不需要调用工具时直接输出最终答案。行为约束包括最大思考轮次、遇到不确定情况怎么处理、禁止编造工具返回值等。这里有个细节很多人会忽略工具参数的描述要足够具体。比如一个查询天气的工具参数 city 要说明是城市名称最好给出示例。模型对参数的理解完全依赖你的描述描述模糊参数就乱填。3.2 输出格式的约定我常用的格式是这样的模型需要调用工具时输出一段以ACTION:开头的文本后面跟一个 JSON 对象包含tool和params两个字段。不需要调用工具时输出以ANSWER:开头的文本后面跟最终答案。选择这种标记式格式而不是纯 JSON是因为纯 JSON 容易和模型的其他输出混在一起解析时不好切分。标记式格式有明确的边界正则一抓就出来。而且标记本身对模型来说是个强提示看到ACTION:就知道该输出结构化内容了。格式示例我会在 Prompt 里给两到三个覆盖单工具调用、多工具调用、直接回答三种情况。示例要真实参数要具体不要用 foo、bar 这种占位符模型会照抄的。3.3 解析器的健壮性设计解析器是整个方案里最需要打磨的部分。我的解析器分三层第一层用正则提取标记块第二层解析 JSON第三层校验字段。第一层正则要写得宽松一点允许模型在标记前后有多余的空格或者换行。第二层 JSON 解析要捕获异常解析失败时记录原始文本方便排查。第三层字段校验要检查必填字段是否存在、类型是否正确、工具名称是否在允许列表里。任何一层失败都不直接报错而是把失败原因作为 Observation 塞回上下文让模型重新生成。这个重试机制非常关键它把模型的偶发错误变成了可恢复的问题。我一般设置最多重试两次两次还不行就返回兜底答案。import re import json ACTION_PATTERN re.compile(rACTION:\s*(\{.*?\}), re.DOTALL) def parse_action(text): match ACTION_PATTERN.search(text) if not match: return None, 未找到 ACTION 标记 raw match.group(1) try: data json.loads(raw) except json.JSONDecodeError as e: return None, fJSON 解析失败: {e} if tool not in data or params not in data: return None, 缺少 tool 或 params 字段 return data, None这段代码是我实际项目里简化出来的核心就是宽松提取加严格校验。注意正则用了非贪婪匹配避免把后面的内容也吞进去。3.4 工具注册与路由工具本身用字典注册键是工具名值是一个包含描述、参数 schema、执行函数的对象。路由的时候根据解析出来的 tool 字段查字典找不到就返回错误信息。每个工具的执行函数我都要求做异常捕获返回统一的格式成功时返回结果字符串失败时返回错误描述。这样模型拿到的 Observation 格式一致不会因为工具报错导致整个流程乱掉。工具描述我建议写得像给新人看的文档说清楚这个工具能做什么、不能做什么、参数怎么填。模型理解工具的能力完全取决于你的描述质量。4. 完整实操从零搭一个能跑的结构化 Agent4.1 环境准备与依赖Python 环境我一般用 3.10 以上主要是类型提示和 match 语法用着舒服。依赖很少核心就是模型调用库其他都是标准库。如果你用的是 OpenAI 兼容接口装个 openai 包就行如果用本地模型看具体推理框架的文档。我特意保持依赖精简就是为了让这套方案容易迁移。不引入 LangChain 这类重框架是因为框架的抽象层会掩盖细节出问题不好排查。自己写两百行代码比调框架调一天强。pip install openai就这一个其他用标准库。requests 都不用模型调用库自带 HTTP 客户端。4.2 工具函数的编写我拿两个工具举例一个是计算器一个是查当前时间。计算器用 Python 的 eval 有安全风险我用 ast 模块做安全求值只允许数字和基本运算符。查时间直接用 datetime。import ast import operator from datetime import datetime OPS { ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv, } def safe_eval(node): if isinstance(node, ast.Num): return node.n if isinstance(node, ast.BinOp): return OPS[type(node.op)](safe_eval(node.left), safe_eval(node.right)) raise ValueError(不支持的表达式) def calculator(expression): try: tree ast.parse(expression, modeeval) return str(safe_eval(tree.body)) except Exception as e: return f计算失败: {e} def get_time(): return datetime.now().strftime(%Y-%m-%d %H:%M:%S)安全求值这块我要多说一句。很多人图省事直接用 eval这在 Agent 场景下是灾难。模型可能生成任何东西eval 等于把执行权交给了模型。用 ast 白名单方式只放行你明确支持的运算这是底线。4.3 主循环的实现主循环的逻辑很直白拼上下文、调模型、解析输出、执行工具、拼回上下文、判断是否结束。def run_agent(user_input, max_turns5): messages [ {role: system, content: SYSTEM_PROMPT}, {role: user, content: user_input}, ] for turn in range(max_turns): response call_model(messages) messages.append({role: assistant, content: response}) action, err parse_action(response) if action is None: if ANSWER: in response: return response.split(ANSWER:, 1)[1].strip() messages.append({role: user, content: f格式错误: {err}请重新输出}) continue result execute_tool(action[tool], action[params]) messages.append({role: user, content: fOBSERVATION: {result}}) return 达到最大轮次未能完成任务这里有几个设计决策值得说。第一工具执行结果用OBSERVATION:标记和 ACTION 对应让模型清楚这是工具返回的内容。第二格式错误时不是直接失败而是把错误信息塞回去让模型重试。第三最大轮次限制是必须的防止模型陷入死循环。4.4 一次完整的执行记录我拿帮我算一下 123 乘以 456再告诉我现在几点这个输入跑一遍。第一轮模型输出ACTION: {tool: calculator, params: {expression: 123 * 456}}。解析成功执行计算器得到 56088。Observation 塞回去。第二轮模型输出ACTION: {tool: get_time, params: {}}。解析成功执行查时间得到当前时间。Observation 塞回去。第三轮模型输出ANSWER: 123 乘以 456 等于 56088现在是 2024-01-15 14:30:22。检测到 ANSWER 标记返回最终答案。整个过程三轮两次工具调用全部走文本协议。你可以把模型换成任何一个能遵循格式的模型流程完全一样。4.5 参数校验的补充实际项目里工具参数往往需要更严格的校验。比如计算器的 expression 参数我会检查长度不超过 200 字符只包含数字、运算符和空格。查时间的工具没有参数但我会检查 params 是不是空字典。这些校验看起来琐碎但能挡住大量模型抽风的情况。我的原则是凡是模型能生成的都要校验。宁可多写几行校验代码也不要让脏数据流进工具函数。5. 踩坑记录与问题排查5.1 模型不遵循格式怎么办这是最常见的问题。模型有时候会忘记标记直接输出 JSON或者在 ACTION 后面跟一段解释再跟 JSON。我的处理方式是Prompt 里把格式规则放在最显眼的位置用分隔线隔开给足示例解析器尽量宽松。如果还是不行我会在系统 Prompt 里加一句任何情况下都必须严格遵循输出格式不要添加额外解释。这句话对大部分模型有效。再不行就换模型有些小模型确实对格式指令不敏感硬调不如换。5.2 JSON 解析失败的排查JSON 解析失败通常有几个原因模型输出了单引号、尾随逗号、注释、或者中文标点。我的解析器在标准 json.loads 失败后会尝试做一轮清洗把单引号换成双引号、去掉尾随逗号、去掉注释。清洗后再解析成功率能提升不少。如果清洗后还是失败就把原始文本记到日志里。我一般会攒一批失败样本分析规律然后针对性地改 Prompt。这个过程很枯燥但效果立竿见影。5.3 工具调用死循环模型有时候会反复调用同一个工具尤其是工具返回的结果不符合它预期的时候。我的处理是在 Observation 里加上轮次提示比如这是第 2 次调用该工具让模型意识到自己在重复。同时设置单个工具的最大调用次数超过就强制中断。还有一种情况是模型在两个工具之间来回跳这种一般是 Prompt 里的工具描述有歧义模型分不清该用哪个。解决办法是把工具描述写得更互斥明确每个工具的适用边界。5.4 常见问题速查表问题现象可能原因排查方向解决手段模型不输出 ACTION 标记Prompt 格式说明不清晰检查系统 Prompt 的格式部分增加示例强化格式指令JSON 解析失败模型输出非法 JSON打印原始输出增加清洗逻辑重试工具名找不到模型编造工具名检查工具列表描述校验工具名返回错误让模型重试参数类型错误参数描述模糊检查参数 schema补充参数说明和示例死循环工具结果不符合预期查看调用历史加轮次提示和调用次数限制上下文爆炸历史记录过长统计 token 数滑动窗口加摘要压缩这张表是我从实际项目里总结出来的基本覆盖了 90% 以上的问题。遇到新问题先对照这张表大部分情况能快速定位。5.5 几个容易被忽视的细节第一个细节是模型输出的稳定性。同一个输入模型可能这次输出 ACTION下次直接输出答案。这不是 bug是模型的随机性。我的做法是在 Prompt 里明确如果需要使用工具才能回答必须先输出 ACTION把决策逻辑写死。第二个细节是 Observation 的格式。我坚持用固定前缀是因为模型对格式化的上下文更敏感。你如果直接把工具返回值裸塞进去模型有时候会分不清这是用户说的还是工具返回的。第三个细节是错误信息的措辞。给模型看的错误信息要具体、可操作比如params 字段缺少 expression 参数而不是参数错误。模型根据你的错误信息来修正信息越具体修正越准。6. 性能优化与扩展方向6.1 Token 消耗的控制结构化方案的 token 消耗主要在三块系统 Prompt、对话历史、模型输出。系统 Prompt 是固定的优化空间在于精简工具描述把不常用的工具说明压缩。对话历史用滑动窗口加摘要前面说过了。模型输出这块可以通过限制最大生成长度来控制一般设 500 token 足够。我实测过同样的任务结构化方案比 Tool Calling 的 token 消耗高 10% 到 20%主要多在格式标记和示例上。但这个代价换来的是模型无关性和可控性我认为值得。如果你的场景对成本极度敏感可以精简示例只保留最核心的一个。6.2 并发与流式输出Agent 主循环本身是串行的因为每一步依赖上一步的结果。但工具执行可以并发如果模型一次输出了多个 ACTION可以并行执行再一起塞回去。不过大部分模型一次只输出一个 ACTION这个优化的收益有限。流式输出对 Agent 来说比较尴尬因为你需要完整输出才能解析 ACTION。我的做法是流式输出给用户看但内部等完整输出再解析。用户体验和解析稳定性兼顾。6.3 多 Agent 协作的扩展这套结构化协议天然适合多 Agent 协作。每个 Agent 有自己的工具集和系统 PromptAgent 之间通过结构化消息通信。一个 Agent 的输出可以作为另一个 Agent 的输入格式统一解析逻辑复用。我做过一个简单的多 Agent 实验一个规划 Agent 负责拆解任务一个执行 Agent 负责调工具一个校验 Agent 负责检查结果。三个 Agent 用同一套结构化协议串起来跑效果比单 Agent 好不少。当然复杂度也上去了是否值得看具体场景。6.4 安全边界的加固Agent 安全是个大话题这里只说和结构化方案相关的部分。因为所有工具调用都经过我的解析器和路由我可以在这一层做细粒度的权限控制。比如某些工具只允许特定用户调用某些参数需要额外校验某些操作需要二次确认。这种集中式的控制点比 Tool Calling 分散在各处的调用要安全得多。我可以在解析器里加审计日志记录每一次工具调用的完整信息出问题能追溯。这是结构化方案在安全上的一个隐性优势。工具执行函数的异常捕获一定要做而且要把异常信息转成模型能理解的描述。直接抛异常会让整个 Agent 崩掉转成 Observation 则能让模型自己想办法绕过。7. 我在这套方案上的一些个人体会这套结构化 Agent 方案我用了大半年从最初的原型到现在的生产系统中间改了很多版。最大的体会是Agent 的可靠性不取决于模型多强而取决于工程做得多细。格式约束、解析校验、重试机制、上下文管理这些看起来不起眼的东西才是决定 Agent 能不能上生产的关键。Tool Calling 不是不能用而是不该被当成唯一路径。把它当成一种可选能力而不是架构的基础假设你的 Agent 会灵活很多。我现在做新项目默认就是结构化方案起步需要的时候再考虑要不要接 Tool Calling 做优化。最后分享一个小技巧把每次 Agent 执行的完整对话历史存下来定期分析。你会发现很多问题是有规律的比如某类输入特别容易触发格式错误某个工具的描述容易引起歧义。这些规律是优化 Prompt 和工具设计的最好依据。我靠这个习惯把工具调用成功率从 85% 提到了 98% 以上比任何框架升级都管用。
阅读完成 · 觉得有帮助?
咨询建站