我为什么不用 OpenAI 的 function calling,自己用正则解析工具调用

一个反主流的选择

现在写 AI Agent,标准做法是:装 openai 包,client.chat.completions.create(tools=[...]),框架帮你把工具调用处理好。LangChain 更省事,agent.run() 一行搞定。

但我写了个 Agent,故意不用 function calling,自己用正则从模型输出里解析工具调用

为什么?因为我发现,用了 SDK 之后,我根本不知道「工具调用」到底发生了什么。它像魔法一样 work,但哪天不 work 了,我连 debug 的抓手都没有。

这篇文章讲清楚:工具调用的本质不是什么高深协议,就是一段双方约定的文本格式。 看懂这点,你就看懂了所有 Agent 框架在做的事。

仓库在这:github.com/lvmaizi/mini-llm-agent


工具调用的本质:一段文本

不管用不用 function calling,流程都是这四步:

  1. 你在 system prompt 里告诉模型「你有这些工具,调用时用这个格式」。
  2. 模型在回复里输出一段符合格式的文本
  3. 你从回复里解析出「它想调哪个工具、传什么参数」。
  4. 执行工具,把结果塞回上下文。

OpenAI 的 function calling 把 1、2、3 步用 JSON Schema 和结构化字段标准化了。但本质仍然是:模型输出符合约定的文本,你来解析。它只是把「解析」这件事故意藏到了 SDK 里。

我选择不藏。在 system prompt 里告诉模型:

bash 复制代码
你可以通过以下格式调用工具:

<tool_call>
{"name": "write_note", "params": {"title": "...", "content": "..."}}
</tool_call>

就一对标签包一段 JSON。没有魔法。


解析器:两条正则的事

核心代码就两条正则:

python 复制代码
# 匹配闭合的工具调用
_CLOSED_RE = re.compile(r"<tool_call>\s*\(.*?)\\s*</tool_call>", re.DOTALL)
# 兜底:max_tokens 截断导致闭标签缺失
_UNCLOSED_RE = re.compile(r"<tool_call>\s*\(.*?)\$", re.DOTALL)

re.DOTALL. 能匹配换行------工具调用里的 JSON 常跨多行。

解析逻辑:

  1. 先用闭合正则找所有成对出现的工具调用。
  2. 如果一个都没找到,但文本里出现了开标签(说明被截断了),用未闭合正则兜底------从开标签取到文本末尾。
  3. 对每段内容,去掉模型常加的 json 围栏,再 json.loads。
  4. 解析失败的记日志后丢弃,不让一个坏调用炸掉整轮。

就这么几行。这就是 SDK 帮你做的事------一个正则加几行处理。


为什么不用 SDK 的 function calling

三个理由。

① 兼容性

function_calling 是 OpenAI 的私有协议,不同厂商支持参差不齐:

  • OpenAI 官方支持,要你传 tools 参数和 JSON Schema。
  • 第三方中转接口透传这个字段,但模型不一定按协议返回。
  • 本地模型(Ollama、vLLM 部署的)很多不支持 function calling,但能输出文本。

我用「标签 + JSON」的纯文本约定,只要模型能输出符合格式的文本就能用------覆盖几乎所有 OpenAI 兼容接口,包括不支持 function calling 的本地模型。

② 透明

用 SDK 时,client.chat.completions.create(tools=[...]) 一行调用,你不知道:

  • 工具说明怎么塞进 prompt 的?
  • 模型返回的工具调用放在 message.tool_calls 还是别处?
  • 多个工具调用怎么排序的?

我项目里,工具说明是拼到 system prompt 里的纯文本,工具调用是从 message.content 里解析出来的纯文本。全程可读、可调。

③ 解析失败不致命

SDK 遇到模型返回畸形 JSON,通常直接抛异常。我的解析器遇到坏调用是记日志后丢弃

python 复制代码
except (json.JSONDecodeError, KeyError, TypeError) as e:
    logger.debug("dropped malformed tool_call: %r (%s)", cleaned[:200], e)
    continue

一个坏调用被跳过,模型这轮被当作「最终答案」处理。系统不会因为模型手抖写错一个引号就崩。


两个真实踩过的坑

坑 1:模型会给 JSON 加代码围栏

模型(尤其被 RLHF 训练过的)输出 JSON 时极爱json 围栏。直接 json.loads 会失败。

所以有个 _strip_fences:先去掉首行的 和末尾的。不加这个,真实模型返回大量解析失败。

坑 2:max_tokens 会把闭标签截断

模型输出一长串,恰好 max_tokens 用完,闭标签没输出就断了。只用闭合正则的话,这个调用会静默丢失

未闭合正则就是为这个兜底------它假设「有开标签没闭标签 = 被截断」,取开标签到文本末尾当一次调用。不完美(可能拿到半截 JSON),但比直接丢掉强。


这套解析值不值得你学

我觉得值得,因为:

  1. 看穿黑盒 :你会明白 LangChain 的 agent.run() 底层在干什么。哪天它出问题,你能 debug。
  2. 不被绑定:换模型、换接口,不用等 SDK 更新支持。只要能输出文本就能调工具。
  3. 能掌控:解析失败怎么处理,你自己说了算,不是 SDK 替你抛异常。

工具调用不是魔法。一段文本约定,两条正则,几行处理。把这个搞懂,你就掌握了 Agent 的地基。


写在最后

这是我从零手搓 AI Agent 的第 2 章。整个项目 8 章,不靠 LangChain,工具调用、ReAct 循环、上下文压缩、路径沙箱全部手写、逐章拆解,每章可独立跑。

完整代码和另外 7 章在这:github.com/lvmaizi/mini-llm-agent

clone 下来能直接跑,还有 11 个测试用例验证 parser 的各种边界行为。觉得有用,给个 star。

下一篇讲 ReAct 循环------怎么让模型在「一次请求」里多轮思考。

相关推荐
回眸&啤酒鸭13 小时前
【回眸】Minicart 电商购物车核心功能落地指南
人工智能
一隅论数智13 小时前
给AI一张“业务概念地图“:本体如何从哲学走向企业智能
大数据·人工智能·经验分享·笔记·学习·学习方法·政务
AI的探索之旅13 小时前
97 个 OpenCV 实例(三十):双目立体,从标定到点云
人工智能·opencv·计算机视觉
AlbertZein13 小时前
Step-5-Preview 上手实测:3D 游戏、金融分析、网页设计一次跑完
人工智能·aigc
LaughingZhu13 小时前
Product Hunt 每日热榜 | 2026-09-19
人工智能·深度学习·神经网络·搜索引擎·百度
美狐美颜SDK开放平台13 小时前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
wukangjupingbb14 小时前
智能网联汽车安全能力框架
人工智能
龙亘川14 小时前
明月照湾区,智启新赛道:从顶流文旅IP盛会看智慧文旅升级路径
人工智能·智慧城市·开源软件·数据可视化
飞猫的边缘AI14 小时前
边缘AI应用:家用AI摄像头怎么做数据训练?
人工智能·边缘计算·ai算法·边缘ai