跳到主要内容

数据截至 (上游 commit 3309bf4e416f)

05 — LLM 封装层:单例、消息格式化、token 计数与重试

这章讲什么: app/llm.py(766 行,全仓最大的单文件)。它对上只暴露 ask / ask_with_images / ask_tool 三个方法,对下处理掉四类脏活: 客户端差异、消息格式、token 会计、失败重试。


1. 单例:按配置名去重

1.1 为什么要单例

因为 token 计数要累计。如果每个智能体各建一个 LLM 实例, 「这次任务一共用了多少 token」就统计不出来了。

1.2 实现

app/llm.py:177-184__new__ 做了个按名字缓存的注册表:

def __new__(cls, config_name: str = "default", llm_config=None):
if config_name not in cls._instances:
instance = super().__new__(cls)
instance.__init__(config_name, llm_config)
cls._instances[config_name] = instance
return cls._instances[config_name]

注意它在 __new__手动调了一次 __init__。Python 之后还会再调一次, 所以 __init__ 开头有个守卫:if not hasattr(self, "client")(app/llm.py:189)—— 第二次进来时 client 已经存在,直接跳过,配置不会被重置。

1.3 一个智能体一套模型

配置名来自智能体名字的小写(app/agent/base.py:52-53), 而配置合并规则在 app/config.py:313-321:[llm] 是基线, [llm.vision][llm.manus] 这类子表继承基线再覆盖

所以「给 Manus 用便宜模型、给 vision 用多模态模型」是配置层就支持的。


2. 三个 ask,一条共同路径

方法用途是否流式谁在用
ask纯文本对话默认 TruePlanningFlow._finalize_plan
ask_with_images显式带图默认 False仓库内未被调用
ask_tool带工具的对话强制 False所有 ToolCallAgent

三者的骨架一致:

format_messages(消息) → 统一成 OpenAI dict

count_message_tokens() → 估算输入 token

check_token_limit() → 超了就抛 TokenLimitExceeded

按模型类型组装 params → 推理模型用 max_completion_tokens

client.chat.completions.create(...)

update_token_count() → 累加统计并打日志

ask_tool 有一句硬编码:params["stream"] = False(app/llm.py:731), 注释写着「Always use non-streaming for tool requests」——工具调用的增量拼接太麻烦, 干脆不做。


3. format_messages:三件安静的事

LLM.format_messages(app/llm.py:266-352)是个静态方法,做三件事:

3.1 把图片折成 content 数组

模型支持图片时,base64_image 字段会被折进 content 列表(app/llm.py:305-335):

message["content"].append(
{
"type": "image_url",
"image_url": {"url": f"data:image/jpeg;base64,{message['base64_image']}"},
}
)
del message["base64_image"]

原本是字符串的 content 会先被转成 [{"type": "text", ...}](:309-312)。

3.2 模型不支持图片就静默丢掉

app/llm.py:337-339:

elif not supports_images and message.get("base64_image"):
del message["base64_image"]

这里藏着一个真实的坑。 supports_images 的判断是 self.model in MULTIMODAL_MODELS(app/llm.py:388),而这份名单是硬编码的 (app/llm.py:35-42):

MULTIMODAL_MODELS = [
"gpt-4-vision-preview", "gpt-4o", "gpt-4o-mini",
"claude-3-opus-20240229", "claude-3-sonnet-20240229", "claude-3-haiku-20240307",
]

而示例配置的默认模型是 claude-3-7-sonnet-20250219 (config/config.example.toml:2)——不在名单里。 于是默认配置下,浏览器截图这类图片会被安静地丢掉,模型只看到文字。 没有日志、没有警告。

3.3 无内容的消息会被丢弃

app/llm.py:341-343:

if "content" in message or "tool_calls" in message:
formatted_messages.append(message)
# else: do not include the message

既没内容也没工具调用的消息不会发出去。这避免了某些 API 对空消息报错。


4. TokenCounter:连图片都按瓦片算

4.1 文本部分

tiktoken 编码计数(app/llm.py:60-62)。模型不在 tiktoken 预设里就退回 cl100k_base(app/llm.py:210-214)——对 Claude 之类的模型来说这只是个近似。

每条消息加固定开销:BASE_MESSAGE_TOKENS = 4,整体再加 FORMAT_TOKENS = 2(app/llm.py:47-48:147-152), 这是 OpenAI 官方计数方法的复刻。

4.2 图片部分:复刻 OpenAI 的瓦片算法

_calculate_high_detail_tokens(app/llm.py:95-116)一步不落地照搬了官方公式:

原图 W×H
│ ① 若超过 2048,等比缩到能塞进 2048×2048

│ ② 再等比缩放,让短边正好 768

│ ③ 数一数需要几块 512×512 的瓦片

tokens = 瓦片数 × 170 + 85

低细节图固定 85 token(app/llm.py:49、79)。拿不到图片尺寸时, high 按 1024×1024 估、其他情况直接返回 1024(app/llm.py:91-93)。

为什么值得学: 图片 token 是 agent 成本里最容易失控的一块(想想每步一张截图), 能在发请求前就估出来,才谈得上做预算控制。

4.3 硬上限

max_input_tokens 若配置了,check_token_limit 就检查 累计已用 + 本次需要 ≤ 上限(app/llm.py:249-254)—— 注意是整个会话的累计值,不是单次请求。 超了抛 TokenLimitExceeded,由 02 章 讲的那段代码 转成「体面收工」。


5. 重试:装饰器与那句不准确的注释

三个 ask 方法都挂着同一个装饰器(app/llm.py:354-360:481-487:637-643):

@retry(
wait=wait_random_exponential(min=1, max=60),
stop=stop_after_attempt(6),
retry=retry_if_exception_type(
(OpenAIError, Exception, ValueError)
), # Don't retry TokenLimitExceeded
)

注释说「不重试 TokenLimitExceeded」,但条件里写了 Exception TokenLimitExceeded 继承 OpenManusError 继承 Exception (app/exceptions.py:8-14),所以它同样会被重试 6 次。

这不是纯理论——ToolCallAgent.think 里那段 isinstance(e.__cause__, TokenLimitExceeded)(app/agent/toolcall.py:61) 恰恰说明:实际抛出来的是重试耗尽后的 RetryError,原异常挂在 __cause__ 上。 上层是按「会被重试」的实际行为写的,只有注释停留在意图上。

后果是:token 一旦超限,要白等 6 次指数退避(最长每次 60 秒)才会被上层处理。


6. 三种客户端

__init__api_type 分三路(app/llm.py:216-225):

api_type客户端说明
azureAsyncAzureOpenAI需要额外的 api_version
awsBedrockClient仓库自研的适配层
其他AsyncOpenAIbase_url 兼容 Ollama、各类中转

6.1 Bedrock 适配层

app/bedrock.py(334 行)干的是「把 Bedrock 的 Converse API 伪装成 OpenAI SDK」: 定义 BedrockClient.chat.completions.create 这条一模一样的调用链 (app/bedrock.py:38-56),内部再把 OpenAI 格式的 tools 翻译成 Bedrock 的 toolConfig

它还有个诚实的临时方案标记——文件顶部的全局变量 CURRENT_TOOLUSE_ID,注释直接写着 # Tmp solution(app/bedrock.py:11-13)。

6.2 推理模型的参数差异

REASONING_MODELS = ["o1", "o3-mini"](app/llm.py:34)。命中时用 max_completion_tokens不传 temperature(app/llm.py:411-417), 因为这些模型不接受采样温度。


7. 流式统计的一个近似

非流式请求可以直接读 response.usage(app/llm.py:429-431)。 流式请求拿不到,于是代码采取了两个近似:

  • 发请求前先把估算的输入 token 记上账(app/llm.py:436)。
  • 收完流后用 tiktoken 编码输出文本,估算补全 token(app/llm.py:453-458)。

所以流式模式下的 token 统计是估算值,和账单会有偏差。


8. 代码地图

主题文件路径符号名
模型封装单例app/llm.pyLLMLLM.__new__
纯文本对话app/llm.pyLLM.ask
带图对话app/llm.pyLLM.ask_with_images
带工具对话app/llm.pyLLM.ask_tool
消息格式化 / 多模态折叠app/llm.pyLLM.format_messages
token 会计app/llm.pyTokenCounterLLM.count_message_tokens
图片瓦片计数app/llm.pyTokenCounter._calculate_high_detail_tokens
限额检查app/llm.pyLLM.check_token_limitLLM.update_token_count
多模态 / 推理模型名单app/llm.pyMULTIMODAL_MODELSREASONING_MODELS
Bedrock 适配app/bedrock.pyBedrockClientChatCompletions
token 超限异常app/exceptions.pyTokenLimitExceeded
模型配置模型app/config.pyLLMSettings
配置合并(基线 + 覆盖)app/config.pyConfig._load_initial_config