原生 function calling 的工具定义放在 tools 参数里,不在 messages 里;只统计消息长度的日志看不到它,流式请求默认也不返回 usage。Autarkia 每轮带了 82 个工具、47,898 字符的 schema,prompt 日志只有两千多字,API 记的是 21k 到 23k token。
用 OpenAI 兼容接口做原生 function calling 时,每个工具的 JSON schema 放在请求的 tools 参数里,和 messages 并列。它和消息文本一样算进 prompt token,但不在 messages 里:只统计消息长度的日志看不到它,打印出来的 prompt 里也没有它。流式请求默认还不返回 usage,要显式传 stream_options={"include_usage": True} 才拿得到真实的 prompt_tokens。三点叠在一起,工具定义的开销可以在所有日志里都不出现。
Autarkia 就是这样。今年 8 月开始记录 API 返回的 usage,一周后统计里主对话每轮的 prompt_tokens 稳定在 21k 到 23k。可我能看到的 prompt 只有两千多字:拼装日志 CompleteMessageList: StaticLen=… 一直是这个量级,打印出来的 system prompt 也是。差出来的部分就是工具定义:那段时间每轮带 82 个工具,序列化以后 47,898 个字符。
01两个 active_tools
奇怪的是,按本轮消息挑工具这件事是做了的。拼 prompt 之前有一个 prepare_active_tools(),先放几个常驻工具,再按关键词往里加,普通闲聊只会得到 4 个:
# context_persona.py:拼 prompt 用
async def prepare_active_tools(agent, message, model_hint) -> List[str]:
active_tools = ["get_current_time", "calculator", "text_to_speech"]
# ……加 web_search,再按关键词路由
return active_tools真正发请求的地方在另一个文件。它没有用上面算好的结果,而是自己去注册表里取:
# model_resolution.py:发请求用
native_tool_names = agent.tool_registry.get_active_tools()
native_tool_names = filter_tool_names(native_tool_names, ...)
openai_tools = agent.tool_registry.get_openai_tools(include_names=native_tool_names)
# registry.py
def get_active_tools(self) -> List[str]:
"""Return currently enabled tool names."""
return [name for name in self._tools if self.is_enabled(name)]两个函数都叫 active tools,意思不一样。prepare_active_tools() 的 active 是「这一轮用得上」,ToolRegistry.get_active_tools() 的 active 是「注册表里没被禁用」。读发送那段代码的时候,get_active_tools() 看上去就是「本轮的工具」,没有任何理由停下来。
02为什么一直没看出来
- 看得见的数字量的都是另一份清单。prompt 文本里描述的工具确实只有那 4 个,长度日志只算
messages,所以每一处能打印出来的东西都和预期一致。 - 发送那边有一行
[Native Tools] Registered 82 tools for function calling,只记个数,不记大小。工具是一个一个加的,6 月 18 日约 45 个,8 月 3 日 66 个,8 月 26 日 81 个,这个数字从来没有一次明显的跳变。 - 6 月 30 日有人专门改过发送这一行,起因是某些工具不该对某个场景可见,于是在
get_active_tools()外面套了一层filter_tool_names()。修完以后这行看起来是「处理过的」,没人再问它为什么要从全量开始。 - 在 8 月 16 日开始记 usage 之前,本地没有任何真实的 token 数。刚开始记的时候还漏了流式请求:OpenAI 兼容接口的流式响应默认不带 usage,要显式传
stream_options={"include_usage": True},而主对话走的正是流式。补上这个参数之后,才看到每轮两万多。
另外还有一份重复:云端模型除了原生 schema,还在文本里收到一份精简的工具清单,同一批工具说明发了两遍。
03怎么改的
8 月 24 日的修法是让「本轮工具」只算一次:流式准备阶段算出 active_tools,prompt 装配和发请求共用同一份,prepare_native_tools() 改成接收这个列表;云端模型不再注入文本工具清单;发送日志加上 schema_chars。之后又加了一个常驻的 search_tools,模型需要别的工具时先搜,命中了再把那几个的 schema 追加进下一轮。普通闲聊从 83 个工具、48,311 字符降到 5 个、1,870 字符。
最近回头看这段代码,还有几处没收干净:
ToolRegistry.get_active_tools()还是这个名字,现在仍有 3 处调用。我打算改成get_enabled_tools()。- 工具发现之后第二次调用
prepare_native_tools()时漏传了mode,函数默认值是"chat",别的模式下第二轮会按聊天模式的规则过滤。这个参数应该去掉默认值,漏传直接报错。 - 流式和非流式各有一份工具选择逻辑,这次和 6 月那次都是两边分别改的。
- 消息长度和工具 schema 长度还是记在两行日志里。一个请求一共多大,应该在调用客户端的那一处记成一行,和返回的
prompt_tokens放在一起。