GPT5 容易被忽略的部分之 自定义工具
在最初的 OpenAI API 中,funcall 参数一直以 JSON 格式传递,这一方式曾被视为事实标准,国内外各大模型也都兼容这种调用方式。
但在实际使用中,我遇到了一些问题,比如让 LLM 输出合法的 JSON 格式仍存在困难(不仅如此,一些兼容性问题和边界情况也时有发生)。
在 GPT5 的 Blog 中 Openai 也有提到尽管我们的模型经过充分训练能够输出 JSON 格式,但当输入内容较长时(例如数百行代码或一份 5 页报告),其出错概率会显著上升。
1
我正在考虑改用 XML 格式结合 React,据我所知,Cline 一直采用 XML 格式。我能想到的这种做法有助于 Agent 直接支持不兼容 OpenAI API 的模型。
OpenRoute openai/gpt-oss-120b for Groq
未能将工具调用参数解析为 JSON
GLM-4.5 zhipu
在调用工具时传入了工具描述中不存在的入参数
解析 XML 标签失败 funcall 内容出现在了 reasoning_content 中
自定义工具
Openai 在推出 GPT5 的同时推出了一种新的工具类型,自定义工具,它允许 GPT‑5 使用纯文本而不是 JSON 来调用工具。通过提供正则表达式 或 lark 信息无关文法 (context-free-grammars)来定义工具调用的格式。
自定义工具的工作原理
自定义工具的核心思想是摆脱严格的 JSON 格式限制,让模型能够以更自然的文本方式调用工具。这种方式有几个显著优势:
- 降低格式错误率:纯文本格式减少了因括号不匹配、引号逃逸等 JSON 格式问题导致的错误
- 更好的兼容性:不受特定 API 格式限制,可以适配各种后端系统
- 更直观的调试:人类可以直接阅读和修改工具调用,无需处理复杂的 JSON 结构
使用正则表达式定义工具格式
最简单的自定义工具实现方式是使用正则表达式来解析模型输出。例如:
import re
# 定义一个简单的计算器工具格式
pattern = r'calculate:\s*(\d+)\s*([+\-*/])\s*(\d+)'
def parse_calculator(text):
match = re.search(pattern, text)
if match:
left, op, right = match.groups()
return {
"function": "calculate",
"arguments": {
"left": int(left),
"operator": op,
"right": int(right)
}
}
return None
上下文无关文法(Context-Free Grammars)
对于更复杂的工具调用场景,OpenAI 推荐使用上下文无关文法来定义工具格式。CFG 提供了比正则表达式更强大的表达能力,能够处理嵌套结构和更复杂的语法规则。
CFG 基础概念
上下文无关文法由以下部分组成:
- 终结符(Terminal symbols):基本的不可再分的符号
- 非终结符(Non-terminal symbols):可以被替换的符号
- 产生式规则(Production rules):定义如何从非终结符生成终结符序列
- 开始符号(Start symbol):文法的入口点
实际应用示例
考虑一个文件操作工具的 CFG 定义:
<tool_call> ::= <function_name> "(" <arguments> ")"
<function_name> ::= "read_file" | "write_file" | "delete_file"
<arguments> ::= <argument> | <argument> "," <arguments>
<argument> ::= <param_name> "=" <value>
<param_name> ::= "path" | "content" | "mode"
<value> ::= <string> | <number> | <boolean>
<string> ::= "\"" <char_sequence> "\""
使用 Python 的lark
库实现:
from lark import Lark
grammar = """
?tool_call: function_name "(" arguments? ")"
function_name: "read_file" | "write_file" | "delete_file"
arguments: argument ("," argument)*
argument: param_name "=" value
param_name: "path" | "content" | "mode"
value: string | number | boolean
string: ESCAPED_STRING
number: NUMBER
boolean: "true" | "false"
%import common.ESCAPED_STRING
%import common.NUMBER
%import common.WS
%ignore WS
"""
parser = Lark(grammar, start='tool_call')
# 解析模型输出
result = parser.parse('write_file(path="/tmp/test.txt", content="Hello World")')
实践建议
- 从简单开始:先用正则表达式处理简单场景,再逐步迁移到 CFG
- 提供示例:在系统提示中给出清晰的工具调用示例
- 错误处理:实现优雅的错误处理和重试机制
- 测试覆盖:为各种边界情况编写测试用例
性能考量
虽然自定义工具提供了更大的灵活性,但也需要注意:
- 解析开销:复杂的 CFG 解析可能比 JSON 解析更耗时
- 内存使用:大型文法可能占用更多内存
- 缓存策略:考虑缓存解析结果以提高性能
碰巧最近在实现一个 QL 规则引擎,一直在写 lark 相关的东西。感觉这个特别适合用来写 安全策略 构建一个规则编写工具来写 nuclei 或者 xray 的策略
附录
参考文章
版权信息
本文原载于 not only security,复制请保留原文出处。