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

相关推荐
正在走向自律12 分钟前
从 Python 基础到大模型落地:读《深入浅出 Python 人工智能》,吃透 AI 工程化 CRUD 实战
开发语言·人工智能·python·机器学习·知识脉络
长沙京卓12 分钟前
MagenticLite 设备需求:能不能本地跑,先分清 App 端和模型端
人工智能·ai
啊阿狸不会拉杆14 分钟前
《计算机网络-自顶向下方法》1.4 分组交换网中的时延、丢包和吞吐量 读书笔记
人工智能·计算机网络
宋哥转AI16 分钟前
深入理解 AI Agent:从黑盒到全链路——生产级 Agent 的可观测性体系怎么建
人工智能·agent·ai编程
Mr.朱鹏16 分钟前
科技周报(2026-09-01期):版权战与机器人潮
人工智能·科技·机器人
秦先生在广东27 分钟前
reverse-skill:面向 AI 编码客户端的逆向工程技能路由包
人工智能
天远Date Lab28 分钟前
分布式微服务实战:基于天远车辆估值构建自动化车价评估网关
人工智能·分布式·微服务·自动化
Proaiapi32 分钟前
2026 年 API 中转与聚合平台选型指南
人工智能
AI工具测评家32 分钟前
2026 知网 & 维普 AIGC 检测底层逻辑解析|快降重 / 笔过 AI / 快将 AI 改写技术差异对比
人工智能·aigc·降重·ai检测·查重·降ai