我为什么不用 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 循环------怎么让模型在「一次请求」里多轮思考。

相关推荐
踏雪亦无痕2 分钟前
论文笔记:Distilling the Knowledge in a Neural Network
论文阅读·人工智能·深度学习·知识蒸馏
soft_hub14 分钟前
如何从采访录音中提取新闻重点?
人工智能
Allen_LVyingbo7 分钟前
生命过程的分子语言:AI 解析范式与场景应用全景研究(下)
大数据·人工智能·知识图谱·健康医疗·数据库开发·数据库架构
豆角焖肉7 分钟前
SpringBoot 集成腾讯 AI 人脸识别
人工智能·spring boot·后端·人脸识别·腾讯 ai·base64 图片
AI情绪识别开源8 分钟前
检信 ALLEMOTION AI驱动振动感知健康分析平台多维度技术参数
人工智能·宽度优先·推荐算法
代码方舟10 分钟前
Go数据工程:利用天远柠檬查出险优化二手车批发拍卖车源出险核查合规体验
人工智能·go
杰克的技术随笔11 分钟前
00_从模型推理到生产运行:2026 年 Agent、Harness 与 Runtime 总体架构
人工智能
AI情绪识别开源11 分钟前
检信ALLEMOTION自主研发的FH256高精度频谱技术
人工智能·深度学习
救救孩子把12 分钟前
AI面试八股文-02_2-Prompt Engineering 面试题
人工智能·面试·llm·prompt·agent·agent记忆·agent面试题
zhidian_AI16 分钟前
约束编码智能体:4 个杠杆,只有 1 个能拒绝
人工智能