← 文章

LLM 请求可观测性:日志只统计 messages,原生 tools schema 的开销不在任何日志里

约 4 分钟#LLM#Python#可观测性#Autarkia

原生 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 个:

python
# 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

真正发请求的地方在另一个文件。它没有用上面算好的结果,而是自己去注册表里取:

python
# 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 放在一起。