数据截至 (上游 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 | 纯文本对话 | 默认 True | PlanningFlow._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 | 客户端 | 说明 |
|---|---|---|
azure | AsyncAzureOpenAI | 需要额外的 api_version |
aws | BedrockClient | 仓库自研的适配层 |
| 其他 | AsyncOpenAI | 靠 base_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.py | LLM、LLM.__new__ |
| 纯文本对话 | app/llm.py | LLM.ask |
| 带图对话 | app/llm.py | LLM.ask_with_images |
| 带工具对话 | app/llm.py | LLM.ask_tool |
| 消息格式化 / 多模态折叠 | app/llm.py | LLM.format_messages |
| token 会计 | app/llm.py | TokenCounter、LLM.count_message_tokens |
| 图片瓦片计数 | app/llm.py | TokenCounter._calculate_high_detail_tokens |
| 限额检查 | app/llm.py | LLM.check_token_limit、LLM.update_token_count |
| 多模态 / 推理模型名单 | app/llm.py | MULTIMODAL_MODELS、REASONING_MODELS |
| Bedrock 适配 | app/bedrock.py | BedrockClient、ChatCompletions |
| token 超限异常 | app/exceptions.py | TokenLimitExceeded |
| 模型配置模型 | app/config.py | LLMSettings |
| 配置合并(基线 + 覆盖) | app/config.py | Config._load_initial_config |