1. 为什么我要绕开 Function Call 做 Agent
先说结论:Function Call 不是 Agent 的必需品,它只是一个"让模型输出结构化意图"的便捷通道。当你手上只有通用对话模型、或者模型厂商的 Function Call 接口不稳定、或者你压根不想被某一家 SDK 绑死的时候,用纯 Prompt 把结构化输出"逼"出来,是完全可行的,而且我在几个项目里跑下来,稳定性比想象中好得多。
这个实践的核心目标很明确:做一个不依赖任何原生 Function Call 能力、纯靠 Prompt 约束就能完成"思考—决策—调用工具—观察结果—继续推理"闭环的通用 Agent。它要能跑在任何支持文本补全或对话的模型上,工具调用格式由我自己定义,解析逻辑由我自己掌控。适合谁看?适合已经写过一两个玩具 Agent、被 Function Call 的兼容性问题折磨过、想搞清楚 Agent 底层到底在干什么的人。如果你连 ReAct 是什么都还没概念,建议先补一下 ReAct 的基本思路再回来,不然中间那几段推理循环的拆解会有点吃力。
我之所以走上这条路,起因很朴素:早期做 Agent 时,我用的模型 Function Call 返回格式在不同版本间反复横跳,今天arguments是字符串,明天变成对象,后天某个字段直接消失。更麻烦的是,一旦我想换模型,整套工具调用的胶水代码就得重写。后来我干脆把 Function Call 整个拿掉,改成让模型按我规定的文本格式输出"我要调用哪个工具、参数是什么",然后我自己写解析器。结果发现,只要 Prompt 设计得当,模型对格式的遵守程度相当高,而且这套逻辑可以无缝迁移到任何模型上。
这篇文章我会把整套设计拆开讲:Prompt 怎么设计才能让模型稳定吐结构化内容、解析器怎么写才能容错、ReAct 循环怎么组织、工具注册怎么做、并发和上下文怎么管、以及我踩过的那些坑。全程 Python 实现,代码可以直接抄。
2. 结构化输出的本质:让模型"填表"而不是"聊天"
2.1 Function Call 到底帮我们做了什么
很多人把 Function Call 当成某种魔法,其实它做的事情非常朴素:约束模型的输出空间,让它必须产出一个符合预定义 schema 的 JSON。模型厂商在训练和对齐阶段,专门强化了模型对工具定义的理解能力,所以当你传入工具列表时,模型知道该在什么时候输出一个结构化的调用请求,而不是继续闲聊。
换句话说,Function Call = 结构化输出约束 + 模型侧的专项对齐。第一部分我们自己能用 Prompt 做,第二部分我们做不了,但可以用"格式示例 + 强约束 + 解析容错"来逼近。
理解这一点很关键,因为它决定了我们绕开 Function Call 之后的策略:我们不是要复制 Function Call 的全部能力,而是要在一个更宽松的约束下,用工程手段把不确定性兜住。
2.2 用 Prompt 定义一套"工具调用协议"
我的做法是自定义一套极简的文本协议。模型每次需要调用工具时,必须输出一个特定标记包裹的 JSON 块,比如:
<tool_call> {"name": "search", "arguments": {"query": "python 邻接矩阵"}} </tool_call>不需要调用工具、只想给出最终答案时,输出:
<final_answer> 这里是给用户的最终回复 </final_answer>这套协议的好处是:标记清晰、易于正则匹配、JSON 部分可以独立解析、和自然语言混排也不会误伤。相比让模型直接输出裸 JSON,加了标记之后解析成功率明显提升,因为模型不容易把解释性文字和结构化内容混在一起。
Prompt 里我会明确写清楚三件事:第一,什么时候该调用工具(需要外部信息、需要计算、需要执行动作时);第二,调用格式长什么样(给出完整示例);第三,一次只能输出一个工具调用,调用后必须等待观察结果再继续。这三条约束缺一不可,尤其是第三条,否则模型会一口气编造多个工具调用的结果,直接跑飞。
2.3 为什么不用裸 JSON 或 XML
我试过三种格式:裸 JSON、XML、自定义标记 + JSON。实测下来:
| 格式 | 解析成功率 | 容错难度 | 模型友好度 |
|---|---|---|---|
| 裸 JSON | 中 | 高(容易和正文混淆) | 中 |
| 纯 XML | 中高 | 中 | 中 |
| 标记 + JSON | 高 | 低 | 高 |
裸 JSON 最大的问题是模型经常在 JSON 前后加解释,比如"好的,我来调用工具:{...}",解析时得先剥离前缀。XML 的问题是嵌套深了模型容易漏闭合标签。标记 + JSON 的组合,本质上是给模型一个明确的"起止信号",它只需要专注填中间的内容,认知负担最小,所以稳定性最好。
提示:标记名不要用太通用的词,比如
<call>这种,容易和正文里的尖括号冲突。用<tool_call>这种带下划线的组合词,冲突概率低很多。
3. 解析器:整个 Agent 最容易被低估的部件
3.1 解析器的职责边界
很多人写 Agent 时把解析器当成一个json.loads就完事,这是大坑。解析器真正要处理的是模型输出的所有不完美情况:JSON 里有多余逗号、字符串没转义、标记只出现了一半、参数类型和预期不符、甚至模型把两个工具调用粘在一起。
我的解析器分三层:第一层用正则提取标记块,第二层对块内内容做 JSON 修复和解析,第三层做 schema 校验和类型转换。任何一层失败,都不直接抛异常终止,而是把错误信息作为"观察结果"喂回给模型,让它自己修正。这一点是纯 Prompt Agent 相比 Function Call 的最大优势:错误可以变成对话的一部分,而不是一个崩溃的异常。
3.2 容错解析的代码实现
import re import json TOOL_CALL_PATTERN = re.compile(r"<tool_call>(.*?)</tool_call>", re.DOTALL) FINAL_ANSWER_PATTERN = re.compile(r"<final_answer>(.*?)</final_answer>", re.DOTALL) def extract_tool_call(text): match = TOOL_CALL_PATTERN.search(text) if not match: return None raw = match.group(1).strip() return parse_json_lenient(raw) def parse_json_lenient(raw): try: return json.loads(raw) except json.JSONDecodeError: pass # 尝试修复常见问题:尾随逗号、单引号 fixed = re.sub(r",\s*([}\]])", r"\1", raw) fixed = fixed.replace("'", '"') try: return json.loads(fixed) except json.JSONDecodeError as e: return {"__parse_error__": str(e), "__raw__": raw}这段代码的关键在于parse_json_lenient:先尝试标准解析,失败后做两步常见修复——去掉尾随逗号、把单引号换成双引号。这两步能救回大概八成的格式错误。剩下的救不回来的,就把原始内容和错误信息打包返回,交给上层决定怎么处理。
3.3 把解析失败变成模型的自我修正机会
解析失败时,我不会重试整个请求,而是构造一条观察消息:
观察结果:你的上一次工具调用格式有误,错误信息:Expecting ',' delimiter。 原始内容:{"name": "search" "arguments": {...}} 请重新输出符合格式的工具调用。模型看到这条消息,绝大多数情况下能自己改对。这比在代码里硬编码各种修复规则要优雅得多,因为修复逻辑交给了模型本身。我实测过,第一次解析失败后,第二次修正的成功率在 90% 以上。
注意:不要把解析失败的原始内容原样喂回去太多次,否则模型可能陷入"复读错误"的循环。我一般设置最多两次修正机会,两次还不行就降级为直接返回文本答案。
4. ReAct 循环的骨架:思考、行动、观察怎么串
4.1 循环的四个阶段
一个完整的 Agent 循环,我拆成四步:构造 Prompt → 模型生成 → 解析输出 → 执行工具并回填观察。这四步循环往复,直到模型输出<final_answer>或者达到最大轮数。
这里有个容易忽略的细节:每一轮都要把历史消息完整带上,包括之前的思考、工具调用、观察结果。因为模型没有记忆,它判断下一步该干什么,完全依赖上下文里能看到的信息。如果你只带最后一条观察,模型会丢失任务目标,开始瞎猜。
4.2 消息历史的组织方式
我用一个列表存消息,每条消息是{"role": ..., "content": ...}。角色有三种:system(放工具定义和协议说明)、user(放任务)、assistant(放模型输出)、tool(放工具执行结果)。有些模型不支持tool角色,那就统一用user角色,在内容前加"观察结果:"前缀,效果一样。
messages = [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": task}, ] for step in range(MAX_STEPS): response = call_model(messages) messages.append({"role": "assistant", "content": response}) tool_call = extract_tool_call(response) if tool_call: result = execute_tool(tool_call) messages.append({"role": "user", "content": f"观察结果:{result}"}) continue final = extract_final_answer(response) if final: return final # 既没有工具调用也没有最终答案,提示模型 messages.append({"role": "user", "content": "请输出工具调用或最终答案。"})这个骨架非常朴素,但它是所有 Agent 的地基。复杂的 Agent 无非是在这四步上做增强:加规划、加反思、加多工具并行、加记忆检索。地基不稳,上面堆再多花活都是空中楼阁。
4.3 最大轮数和死循环防护
一定要设MAX_STEPS,我一般设 10 到 15。超过就强制终止,返回当前最好的结果或者报错。没有这个上限,模型可能陷入"调用工具—观察—再调用同一个工具"的死循环,尤其是当工具返回的结果不满足它的预期时。
除了轮数上限,我还会检测重复调用:如果连续两轮调用了同一个工具、参数也几乎一样,就注入一条提示"你已经调用过这个工具,结果是 X,请基于此继续或换一个思路"。这一招能救回不少卡死的任务。
5. 工具注册:让 Agent 知道自己有什么武器
5.1 工具描述怎么写模型才看得懂
工具注册的核心不是代码,是描述文本。模型判断该不该调用某个工具,完全依赖你给的描述。描述写得好,调用准确率翻倍;写得含糊,模型要么不用,要么乱用。
我的工具描述模板包含四部分:名称、功能一句话、参数说明、使用场景。比如:
工具名:search 功能:在互联网上搜索信息 参数: - query (string, 必填):搜索关键词 使用场景:当你需要获取实时信息、事实核查、或知识库之外的内容时使用"使用场景"这一条最容易被省略,但它恰恰是模型决策的关键。告诉模型"什么时候用",比告诉它"能做什么"更重要。
5.2 工具注册表的代码结构
class ToolRegistry: def __init__(self): self.tools = {} def register(self, name, func, description, schema): self.tools[name] = { "func": func, "description": description, "schema": schema, } def describe_all(self): lines = [] for name, t in self.tools.items(): lines.append(f"工具名:{name}\n功能:{t['description']}\n参数:{t['schema']}") return "\n\n".join(lines) def execute(self, name, arguments): if name not in self.tools: return f"错误:不存在名为 {name} 的工具" try: return self.tools[name]["func"](**arguments) except Exception as e: return f"工具执行出错:{e}"describe_all的输出直接拼进 System Prompt,模型就能看到所有可用工具。execute里对未知工具和异常都做了兜底,返回错误字符串而不是抛异常,这样错误能作为观察结果回传给模型。
5.3 参数校验不能省
模型给的参数经常有类型问题,比如该传整数传了字符串、该传列表传了单个值。我在execute之前加一层轻量校验,根据 schema 做类型转换和必填检查。校验失败不要直接报错终止,而是返回一条清晰的提示,比如"参数 query 是必填项,你漏了",模型看到后通常能补上。
6. 上下文管理:Agent 跑久了会"失忆"和"爆窗"
6.1 上下文膨胀的真实速度
一个稍微复杂的任务,跑个七八轮,每轮的工具返回可能几百上千字,上下文轻松破万 token。如果不管理,要么撞上模型的上下文上限,要么成本飙升。我做过统计,一个中等复杂度的调研任务,不做任何裁剪的话,平均消耗是必要信息的 3 到 5 倍。
6.2 三种裁剪策略
我的做法是分层裁剪:
- 工具结果截断:单个工具返回超过 2000 字符的,只保留前 1500 和后 300,中间用省略号。绝大多数任务不需要完整的长文本。
- 历史轮次压缩:超过 N 轮之后,把早期的"思考 + 工具调用 + 观察"压缩成一句摘要,比如"第 1-3 轮:搜索了 X,得到了 Y 的结论"。
- 关键信息锚定:把任务目标、已确认的关键事实单独存一份,每轮都带上,防止被裁剪掉。
这三种策略组合使用,能把上下文控制在合理范围内,同时不丢失关键信息。
6.3 别让裁剪破坏推理链
裁剪有个大坑:如果把模型上一步的思考裁掉了,它下一步的推理会断裂。所以裁剪时,最近两轮的完整内容必须保留,只裁更早的。这个边界要卡准,我一般保留最近 2 轮完整、更早的做摘要。
提示:摘要不要用另一个模型去生成,成本高且慢。用规则化的模板拼一句话就够了,比如"第 X 轮调用了工具 Y,参数 Z,返回结果摘要:..."。
7. 我踩过的坑和对应的解法
7.1 模型"假装"调用了工具
最开始的版本,模型经常在正文里写"我将调用 search 工具搜索...",但根本不输出<tool_call>标记。原因是我的 Prompt 里对格式的强调不够强。解法是在 System Prompt 里用加粗 + 重复 + 反例三重强调:明确说"不要用自然语言描述你要调用工具,必须输出标记块",并给一个错误示例和一个正确示例对照。
7.2 参数里塞了多余的解释
模型有时会在 JSON 参数里加注释,比如{"query": "python 教程" // 搜索关键词}。这在标准 JSON 里是非法的,直接解析失败。我的解法是在parse_json_lenient里加一步:用正则去掉//到行尾的内容。这个修复规则救回了不少 case。
7.3 工具返回太长导致模型"读不完"
有一次工具返回了一篇长文,模型在下一轮直接忽略了内容,继续调用别的工具。原因是长文本淹没了任务目标。解法就是上面说的截断策略,同时把任务目标在每轮末尾再强调一次。
7.4 并发场景下的状态污染
如果你要同时跑多个 Agent 实例,千万别用全局变量存消息历史。我早期图省事用了一个模块级列表,结果两个任务互相串消息,输出完全乱套。后来改成每个任务一个独立的AgentSession对象,所有状态都挂在实例上,问题消失。
| 坑 | 现象 | 解法 |
|---|---|---|
| 假装调用工具 | 正文描述但不输出标记 | Prompt 强约束 + 正反例 |
| 参数带注释 | JSON 解析失败 | 正则去注释 |
| 长返回淹没目标 | 模型忽略观察结果 | 截断 + 目标重述 |
| 全局状态污染 | 多任务串消息 | 实例化 Session |
8. 并发与性能:Agent 怎么扛住多任务
8.1 瓶颈到底在哪
Agent 的性能瓶颈几乎永远在模型调用上,不在你的 Python 代码。一次模型调用几百毫秒到几秒不等,工具执行通常快得多。所以优化方向很明确:减少模型调用次数、并行化独立的模型调用、缓存可复用的结果。
8.2 用异步并发跑多任务
如果每个任务之间独立,用asyncio并发跑是最直接的。把模型调用和工具执行都写成 async 函数,用asyncio.gather批量执行。我实测过,10 个独立任务串行跑要 30 秒,并发跑只要 5 秒左右,提升非常明显。
import asyncio async def run_agent(task): session = AgentSession() return await session.run(task) async def main(tasks): results = await asyncio.gather(*[run_agent(t) for t in tasks]) return results注意模型 API 一般有并发限制,别一次性发太多,加个信号量控制并发数。
8.3 单任务内部的并行
有些任务里,模型会连续调用几个互不依赖的工具。这种情况可以在解析出多个工具调用时并行执行。但前提是你的 Prompt 允许模型一次输出多个调用,这又增加了格式复杂度。我的建议是:先做单调用,稳定之后再考虑多调用并行,别一上来就追求极致。
9. 写在最后的一点个人体会
这套无 Function Call 的结构化 Agent,我从第一版到现在迭代了大概五六次,最大的感受是:Agent 的难点从来不在"调用工具"这个动作本身,而在于如何让模型在长链条推理中保持目标不漂移、格式不崩坏、错误能自愈。Function Call 帮你解决了格式问题,但解决不了目标漂移和错误自愈,这些还是得靠 Prompt 设计和循环控制来做。
我现在更倾向于把这套纯 Prompt 方案当成"理解 Agent 本质"的训练场。当你能用最朴素的文本协议把 Agent 跑通,再回头看那些封装好的框架,你会发现它们做的事情你全都懂,用起来也更知道哪里可能出问题。至于生产环境用不用 Function Call,那是另一个维度的取舍——要极致稳定就用原生能力,要极致灵活和可迁移就用纯 Prompt,两者并不矛盾,甚至可以混用:关键工具走 Function Call,边缘工具走文本协议。
最后分享一个小技巧:调试 Agent 时,把每一轮的完整消息历史打印出来,用分隔线隔开。你会非常直观地看到模型在哪一步开始跑偏,比看最终输出有用得多。这个习惯帮我定位了至少一半的诡异 bug。