1. 为什么我要绕开 Tool Calling 做 Agent
1.1 一个被过度神化的机制
过去一年里,只要聊到 Agent 开发,几乎所有人第一反应都是 Tool Calling。模型厂商把它包装成 Agent 的标配能力,各种框架也把它当成默认路径。我一开始也是这么干的,接上工具定义、让模型自己决定调哪个函数、拿到返回值再继续推理,看起来非常优雅。
但真到了生产环境,问题就来了。Tool Calling 本质上依赖模型厂商在推理层面对特定格式的支持,不同厂商的实现细节差异极大。同一个工具定义,在 A 模型上跑得好好的,换到 B 模型就开始乱调参数,甚至干脆不调。更麻烦的是,一旦你用的是本地部署的开源模型,或者某些不支持原生 Tool Calling 的推理接口,整条链路直接断掉。
我踩过最狠的一次坑,是给一个客户做内部知识库 Agent。开发阶段用的是某家 API,Tool 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(r"ACTION:\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, f"JSON 解析失败: {e}" if "tool" not in data or "params" not in data: return None, "缺少 tool 或 params 字段" return data, None这段代码是我实际项目里简化出来的,核心就是宽松提取加严格校验。注意正则用了非贪婪匹配,避免把后面的内容也吞进去。
3.4 工具注册与路由
工具本身用字典注册,键是工具名,值是一个包含描述、参数 schema、执行函数的对象。路由的时候根据解析出来的 tool 字段查字典,找不到就返回错误信息。
每个工具的执行函数我都要求做异常捕获,返回统一的格式:成功时返回结果字符串,失败时返回错误描述。这样模型拿到的 Observation 格式一致,不会因为工具报错导致整个流程乱掉。
工具描述我建议写得像给新人看的文档,说清楚这个工具能做什么、不能做什么、参数怎么填。模型理解工具的能力,完全取决于你的描述质量。
4. 完整实操:从零搭一个能跑的结构化 Agent
4.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, mode="eval") 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_turns=5): 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": f"OBSERVATION: {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 有自己的工具集和系统 Prompt,Agent 之间通过结构化消息通信。一个 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% 以上,比任何框架升级都管用。