一个反主流的选择
现在写 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,流程都是这四步:
- 你在 system prompt 里告诉模型「你有这些工具,调用时用这个格式」。
- 模型在回复里输出一段符合格式的文本。
- 你从回复里解析出「它想调哪个工具、传什么参数」。
- 执行工具,把结果塞回上下文。
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 常跨多行。
解析逻辑:
- 先用闭合正则找所有成对出现的工具调用。
- 如果一个都没找到,但文本里出现了开标签(说明被截断了),用未闭合正则兜底------从开标签取到文本末尾。
- 对每段内容,去掉模型常加的
json围栏,再 json.loads。 - 解析失败的记日志后丢弃,不让一个坏调用炸掉整轮。
就这么几行。这就是 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),但比直接丢掉强。
这套解析值不值得你学
我觉得值得,因为:
- 看穿黑盒 :你会明白 LangChain 的
agent.run()底层在干什么。哪天它出问题,你能 debug。 - 不被绑定:换模型、换接口,不用等 SDK 更新支持。只要能输出文本就能调工具。
- 能掌控:解析失败怎么处理,你自己说了算,不是 SDK 替你抛异常。
工具调用不是魔法。一段文本约定,两条正则,几行处理。把这个搞懂,你就掌握了 Agent 的地基。
写在最后
这是我从零手搓 AI Agent 的第 2 章。整个项目 8 章,不靠 LangChain,工具调用、ReAct 循环、上下文压缩、路径沙箱全部手写、逐章拆解,每章可独立跑。
完整代码和另外 7 章在这:github.com/lvmaizi/mini-llm-agent
clone 下来能直接跑,还有 11 个测试用例验证 parser 的各种边界行为。觉得有用,给个 star。
下一篇讲 ReAct 循环------怎么让模型在「一次请求」里多轮思考。