第 2 篇:「一个 toml 走天下」—— 配置系统与多供应商 LLM 封装

系列:OpenManus 源码级深度解读(master @ 3309bf4e416fb1c74b008f3e86494439a31bad53
本篇覆盖:DeepWiki ch2(Configuration)+ ch4(LLM Integration)
核心源码app/config.py(372 行)、app/llm.py(766 行)、app/bedrock.py(334 行)、config/config.example.toml(113 行)
阅读本文你将了解: 一个 toml 文件如何驱动七个子系统;Config 双检锁单例与 example 回退的利弊;LLM 按 config_name 的单例路由如何实现"主模型 + vision 模型"双通道;token 预算控制为何被 tenacity 白白重试 6 次;Bedrock 适配层用 334 行解决什么问题、又留下什么隐患。


1. 为什么值得解剖配置层与 LLM 层

Agent 框架的"配置 + LLM 封装"看起来是最没有技术含量的部分——读文件、发 HTTP 请求。但 OpenManus 这两层藏着不少值得玩味的工程决策:

其一,它是多供应商兼容的最小实现。不引入 litellm 这类统一网关,而是用"OpenAI SDK 为骨架 + Azure/AWS 手工适配"的方式,用不到 1100 行代码接入了 OpenAI 协议、Azure OpenAI、AWS Bedrock、Ollama 四种后端(config/config.example.toml:1-45 的四段注释模板)。这是理解"协议适配层该多薄"的很好参照。

其二,它有两个互相独立的单例Config 是进程级单例(app/config.py:197-215),LLM 是按 config_name 的命名单例app/llm.py:174-184)。后者让 Manus(用 default 模型)和浏览器 Agent(用 vision 模型)可以在同一个进程里走不同的 API 端点,这个模式在多模型 Agent 系统里非常实用。

其三,它的 token 预算控制有一个注释与实现背离的真实缺陷(app/llm.py:637-643),是所有用 tenacity 做 LLM 重试的团队都该看一眼的反面教材。

2. Config:双检锁单例与 example 回退

Config 类用标准的双检锁实现线程安全单例:

# app/config.py:197-215
class Config:
    _instance = None
    _lock = threading.Lock()
    _initialized = False

    def __new__(cls):
        if cls._instance is None:
            with cls._lock:
                if cls._instance is None:
                    cls._instance = super().__new__(cls)
        return cls._instance

有意思的是状态标记的分工:_instance 防止对象重复创建,_initialized(类变量)防止 __init__ 重复加载配置——因为 Python 的 __init__ 在单例模式下会被多次调用。config = Config()app/config.py:372)在模块导入时就完成实例化,这意味着任何 import 到 config 的模块都会触发 toml 解析,配置错误会在启动的第一时间暴露,这算是隐式 import 副作用被正向利用的例子。

配置文件查找逻辑有一个静默回退:

# app/config.py:217-226
@staticmethod
def _get_config_path() -> Path:
    root = PROJECT_ROOT
    config_path = root / "config" / "config.toml"
    if config_path.exists():
        return config_path
    example_path = root / "config" / "config.example.toml"
    if example_path.exists():
        return example_path
    raise FileNotFoundError("No configuration file found in config directory")

零配置可跑是这份设计的善意:新用户 clone 下来不建 config.toml 也能 python main.py 起来。但它同时是一颗生产地雷——config.example.toml 里写着 api_key = "YOUR_API_KEY"config/config.example.toml:4),忘配 key 的用户会一路跑到 LLM 调用才收到 401,而且日志里看到的是"Authentication failed"而不是"你没写配置文件"。错误被推迟到了最远端才爆炸,这是"静默回退"类设计的典型代价。

3. LLM 多段配置:default + 命名覆盖的合并算法

[llm] 表的解析是整个配置层最精巧的一段。toml 里允许这样写:

# config/config.example.toml:1-7 与 42-45
[llm]
model = "claude-3-7-sonnet-20250219"

[llm.vision]
model = "claude-3-7-sonnet-20250219"
base_url = "https://api.anthropic.com/v1/"

_load_initial_config[llm] 下的标量键收集为 default 设置,把子表(如 [llm.vision])收集为命名覆盖,然后做一层浅合并:

# app/config.py:313-320
config_dict = {
    "llm": {
        "default": default_settings,
        **{
            name: {**default_settings, **override_config}
            for name, override_config in llm_overrides.items()
        },
    },
    ...
}

{**default, **override} 意味着 [llm.vision] 只需写差异字段(比如只换 model 和 base_url),api_key、temperature 等自动继承主配置。消费端只需要一句 config.llm.get(config_name, config.llm["default"])app/llm.py:191)就能完成路由——配置继承在加载期一次性做完,运行期零开销

值得对比的是 MCP 配置走了另一条路:mcp 段在 toml 里几乎为空,真正的服务器列表从独立的 config/mcp.json 加载(app/config.py:299-306 调用 MCPSettings.load_server_config(),实现在 app/config.py:148-171)。原因是 MCP server 配置格式要和业界通用的 mcpServers JSON 约定对齐(Claude Desktop 等工具的同一格式),兼容生态格式比统一配置格式更重要——这是个值得记住的取舍。

4. 七类配置段全景

AppConfigapp/config.py:174-194)聚合了七个配置段,各段的关键默认值如下表(均为实测源码值):

配置段数据类关键默认值位置
llmLLMSettingsmax_tokens=4096,temperature=1.0app/config.py:19-30
browserBrowserSettingsdisable_security=True,headless=Falseapp/config.py:69-91
searchSearchSettings主引擎 Google,回退 DuckDuckGo→Baidu→Bingapp/config.py:39-60
sandboxSandboxSettingspython:3.12-slim / 512m / 1.0 CPU / 网络关闭app/config.py:94-105
daytonaDaytonaSettings云沙箱镜像 whitezxj/sandbox:0.1.0,VNC 密码硬编码 “123456”app/config.py:108-124
mcpMCPSettingsservers 从 config/mcp.json 加载app/config.py:138-171
runflowRunflowSettingsuse_data_analysis_agent=Falseapp/config.py:63-66

三个默认值值得点名:

temperature=1.0app/config.py:28)。example toml 里写的是 temperature = 0.0config/config.example.toml:6),所以实际跑起来是 0——但 Pydantic 层的默认是 1.0。两处默认不一致,意味着"手动构造 LLMSettings 不走 toml"的测试代码会和生产行为不一致。Agent 场景 temperature=1.0 明显偏高(代码生成容易漂移),这个默认值的选择值得商榷。

disable_security=Trueapp/config.py:71-73)。browser-use 默认关掉浏览器的安全特性(同源策略等),为了让 Agent 能跨域操作页面。这是能力优先于安全的取舍,放在 04 篇浏览器主题里再展开。

Daytona VNC 密码硬编码 “123456”app/config.py:122-124)。写在 Pydantic Field default 里的密码,几乎不会被用户改掉。云沙箱 + 弱密码的组合在生产环境是不可接受的,06 篇会回头看这个问题。

5. LLM 门面:命名单例与三分支客户端

LLM 类的单例方式和 Config 不同——它按 config_name 维护一个实例字典:

# app/llm.py:174-184
class LLM:
    _instances: Dict[str, "LLM"] = {}

    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]

注意这里直接调用了 instance.__init__()——绕过了 Python 正常的构造协议。配套地,__init__ 里用 if not hasattr(self, "client") 防止重复初始化(app/llm.py:189)。这种"手动 orchestration"写法比 Config 的双检锁更紧凑,但代价是初始化逻辑不经过 __init__ 的正常调用链,子类化和调试断点都更别扭。两种单例写法并存于同一个代码库,本身就是一个有意思的对照实验。

构造函数的核心是客户端三分支(app/llm.py:216-225):

  • api_type == "azure"AsyncAzureOpenAI(多带一个 api_version)
  • api_type == "aws"BedrockClient()(本地实现,见第 8 节)
  • 其他一律 → AsyncOpenAI(api_key, base_url)

第三条分支是整个多供应商策略的基石:只要供应商兼容 OpenAI Chat Completions 协议(DeepSeek、Qwen、GLM、Ollama、vLLM……),零适配代码直接接入api_type 甚至不做校验——example toml 里的 jiekou.ai 段没有写 api_type,走的就是这条默认分支。这个设计的含义是:适配成本被压到了"写四行 toml",换来的是新供应商接入零代码。

LLM(config_name="vision") 的第二个实例就是这么来的:浏览器 Agent 等需要看图的组件用 vision 配置段,主 Agent 用 default 段,两个实例各自维护独立的 client、tokenizer 和 token 计数器,互不干扰。命名单例在此处不是炫技,是多模型 Agent 的刚需。

tokenizer 初始化有一个降级(app/llm.py:209-214):tiktoken.encoding_for_model(self.model) 查不到预设(Claude、国产模型必然查不到)时回退 cl100k_base。对 GPT 系模型计数精确,对其他模型只是估算——token 预算控制在非 OpenAI 模型上天然带 5-15% 的误差,后面第 7 节的预算机制全部建立在这个估算之上。

先把这两层的关系画出来:Config 单例持有 AppConfig 聚合根,七个配置段里只有 llm 是必填的字典结构(支持多命名实例),其余六段全部 Optional;LLM 命名单例从 Config.lll 字典里按名字取配置,运行期只依赖选定的 client 外壳——AsyncOpenAI / AsyncAzureOpenAI / BedrockClient 三选一,对上层暴露同一套 chat.completions.create 属性链:

在这里插入图片描述

图上有两处结构信息值得停留:一是 AppConfig 里只有 llm 字段是必填的字典,其余段全部 Optional——配置系统的权重分布一目了然,LLM 配置是唯一"配错就起不来"的段;二是 LLM 到 client 的三条依赖线不是继承而是组合,三个 client 外壳没有共同基类,全靠鸭子类型约束接口一致,这正是 Bedrock 适配能"不改 LLM 主层"的结构前提,也是它没有编译期保障的风险来源。

6. TokenCounter:消息与图片的 token 估算

TokenCounterapp/llm.py:45-171)把 OpenAI 的计费规则翻译成了本地估算器,三块逻辑:

文本:每条消息 4 个基础 token + 2 个格式 token + role/正文/tool_call 参数逐段 encode(app/llm.py:147-171)。这个"每条消息固定开销"的模型和 OpenAI 官方文档的计费说明一致。

图片:low detail 固定 85 token;high detail 按"缩到 2048 内 → 短边缩到 768 → 数 512px tile(每块 170)→ +85"计算(app/llm.py:64-116)。这是对 OpenAI vision 计费公式的忠实复刻,注释里连公式步骤都写全了(app/llm.py:68-74)。

工具 schema:不算在 TokenCounter 里,而是 ask_tool 里直接对工具定义做字符串计数(app/llm.py:694-699):

# app/llm.py:694-699(节选)
tools_tokens = 0
if tools:
    for tool in tools:
        tools_tokens += self.count_tokens(str(tool))
input_tokens += tools_tokens

str(tool) 把整个工具 JSON schema 序列化后数 token——粗糙(schema 的 JSON 语法符号也计费了),但在"工具描述占掉几千 token"的 Agent 场景里,这个方向的误差是保守的(多算不少算),对预算控制反而是安全的。

7. token 预算与重试:一处注释与实现背离的缺陷

预算控制的调用链是:请求前估算 input_tokens → check_token_limitapp/llm.py:249-254max_input_tokens 未配置时永远通过)→ 超限抛 TokenLimitExceededapp/exceptions.py:12-14,继承自 Exception)→ 期望它不被重试直接击穿。

问题出在 tenacity 装饰器上:

# app/llm.py:637-643(ask_tool 的装饰器,ask/ask_with_images 同型)
@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
)

注释写着 “Don’t retry TokenLimitExceeded”,但 retry_if_exception_type 的语义是**“命中列表中的类型才重试”**,列表里的 Exception 是几乎所有异常的基类——TokenLimitExceeded 继承自 Exception,必然命中,照样重试满 6 次,每次还要经历最长 60 秒的指数退避等待。也就是说一次明确的"预算超限"失败要等上几十秒到几分钟才能被上层感知。

上层确实为这个场景做了补丁:ToolCallAgent.think() 捕获 RetryError 后检查 __cause__ 是否为 TokenLimitExceeded,是则置 FINISHED 优雅停车(app/agent/toolcall.py:60-73,01 篇第 7 节已分析)。补丁能兜底,但"超限→重试 6 次→RetryError→再识别"这条链路白白消耗了几分钟。

正确的写法应该是反向条件:retry=retry_if_not_exception_type(TokenLimitExceeded)。这个 bug 的教学价值在于:tenacity 的 retry 条件列表里放宽基类时,注释声明的意图已经不重要了,类型关系决定一切。事实上 ValueError 在列表里也是冗余的——它同样是 Exception 的子类。

ask_tool 一次调用的完整控制流画出来,预算检查和重试的咬合关系会更直观:

在这里插入图片描述

这张时序图把本篇最重要的两个缺陷串在了同一条链路上:预算超限路径(左分支)里,TokenLimitExceeded 本该直达上层,却被 tenacity 的重试条件拦下重跑 6 次,最后以 RetryError 的包装形态到达 ToolCallAgent,迫使上层再做一次 __cause__ 解包;正常路径(右分支)里,无效响应的静默 return None 则让"LLM 响应损坏"和"模型决定不调工具"在调用方看来毫无区别。预算机制设计是对的,错误传播路径是漏的——这是对这套 LLM 门面最准确的一句概括。

8. 三个 ask:一套门面、三种协议形态

LLM 对外暴露三个入口,差异用一张表说清:

方法协议形态流式图片重试关键位置
ask纯文本 chat默认 True自动识别6 次app/llm.py:354-479
ask_with_images多模态 content 数组默认 False显式传入6 次app/llm.py:481-635
ask_toolfunction calling强制 False自动识别6 次app/llm.py:637-766

三个方法共享同一套前置逻辑:format_messages 归一化(含 base64_image 到 OpenAI image_url content 的转换,app/llm.py:304-339,不支持图片的模型则静默丢弃图片字段)、token 估算、预算检查。差异点在后半段。

ask 的流式直打 stdoutask 默认 stream=True,chunk 用 print(chunk_message, end="", flush=True) 直接打到标准输出(app/llm.py:446)。Demo 里这是"实时看到 Agent 思考"的爽点;生产里这是日志污染源——流式内容和 logging 的输出会交错在同一控制台。流式路径的 completion token 是事后估算的(app/llm.py:453-458,因为流式响应没有 usage 字段)。

ask_with_images 有一个硬编码模型清单。多模态判定靠 MULTIMODAL_MODELS 列表(app/llm.py:35-42),清单里是 gpt-4o 和 claude-3 早期型号。这意味着 claude-3.7-sonnet(example toml 的默认模型)不在清单里ask_with_images 会直接抛 “does not support images”(app/llm.py:518-521)。这是硬编码清单模式的典型腐化:模型迭代速度远超清单维护速度。相比之下 ask/ask_toolsupports_images = self.model in MULTIMODAL_MODELSapp/llm.py:388,681)只是决定要不要内嵌 base64 图片,判定失误的后果轻得多。

ask_tool 强制非流式app/llm.py:731params["stream"] = False),因为 function calling 需要完整解析 tool_calls 结构。它还有一个值得警惕的分支:

# app/llm.py:737-740
if not response.choices or not response.choices[0].message:
    print(response)
    # raise ValueError("Invalid or empty response from LLM")
    return None

无效响应静默返回 None,raise 被注释掉,print(response) 是调试残留。调用方 ToolCallAgent.think() 对 None 的处理是当"无工具调用"走文本分支——LLM 响应损坏时 Agent 不会报错,而是把这轮当成"模型决定只说话"。错误被降级成了行为怪异,排查时非常隐蔽。

reasoning 模型分支REASONING_MODELS = ["o1", "o3-mini"]app/llm.py:34)命中时参数换成 max_completion_tokens 且不带 temperature(app/llm.py:723-729,o1 系不接受 temperature)。清单式判定在这里问题不大(o1 系参数约定稳定),但和 MULTIMODAL_MODELS 一样属于"代码追着模型清单跑"的模式。

9. Bedrock 适配:334 行换一层协议

app/bedrock.py 解决一个具体问题:AWS Bedrock 的 Converse API 与 OpenAI SDK 的接口形态不同,但 LLM 的代码按 OpenAI SDK 的 self.client.chat.completions.create(...) 调用。适配策略是给 BedrockClient 造一个同构的接口外壳

# app/bedrock.py:37-46(节选)与 app/llm.py:222-223
class BedrockClient:
    def __init__(self):
        self.client = boto3.client("bedrock-runtime")
        self.chat = Chat(self.client)

# LLM 侧无缝挂载
elif self.api_type == "aws":
    self.client = BedrockClient()

BedrockClient.chat.completions.create() 这个调用链完全模仿 OpenAI SDK 的属性路径(app/bedrock.py:/1-58),内部做三件事:OpenAI 消息 → Bedrock Converse 格式的转换(app/bedrock.py:/1)、OpenAI tools → Bedrock toolSpec 转换(app/bedrock.py:/1-84)、调 client.converse / converse_streamapp/bedrock.py:/1-218, 220-240)。响应侧用 OpenAIResponse 把 Bedrock 返回的 dict 动态包装成属性访问对象(app/bedrock.py:/1-34),让 response.choices[0].message.content 这类链式取值成立。

这个"鸭子类型协议适配"的取舍很清晰:不改 LLM 主层一行代码,把协议差异全部关进 334 行的适配文件。但实现质量明显是"能跑"级别:

  1. 初始化失败直接 sys.exit(1)app/bedrock.py:/1-46)——一个库模块在 import 链里杀进程,单元测试都无法隔离。
  2. CURRENT_TOOLUSE_ID = None 模块级全局变量跨请求传递 tool use ID(app/bedrock.py:/1-13),注释自己承认 “Tmp solution”——并发场景下必然串号。
  3. 认证完全依赖 AWS 环境变量(boto3 默认链),api_key 配置项形同虚设(example toml 里注释了 “Required but not used for Bedrock”,config/config.example.toml:15)。

对读源码的人来说,这个文件是"适配层应该多薄"的反面参照:同构外壳的思路是对的,但用全局变量传状态、用 sys.exit 处理错误,说明它还没经历过生产打磨。

10. 生产视角:上生产前要改的四件事

结合第 3-9 节的源码事实,如果你要基于这层做生产系统,按优先级:

第一,修 token 重试。把三个 retry 装饰器的条件改为 retry_if_not_exception_type(TokenLimitExceeded)app/llm.py:354-360, 481-487, 637-643 三处)。一行改动,省掉每次预算超限的数分钟等待。

第二,配置回退加告警_get_config_path 回退 example 时至少打一条 WARNING(app/config.py:223-225),或者干脆在生产构建里禁掉回退。同时把 temperature 的 Pydantic 默认从 1.0 降到 0.x,与 example 对齐(app/config.py:28)。

第三,堵 ask_tool 的 None 分支return None 改回 raise,或者在 ToolCallAgent 层对 None 显式计数告警(app/llm.py:737-740)。静默吞错误的代价会在最难排查的时刻显现。

第四,token 计数器分实例监控update_token_count 只打日志不落指标(app/llm.py:238-247),生产上要把 total_input_tokens/total_completion_tokens 接到 metrics 系统——它已经按 LLM 实例天然分好了维度(default/vision 各自独立计数),接线上几乎是现成的。

11. 进阶视角:扩展点与同类对比

加一个新供应商:兼容 OpenAI 协议的(绝大多数国产大模型)只需一段 toml(api_type 留空走默认分支);不兼容的(Bedrock 型)参照 app/bedrock.py 写同构外壳,在 app/llm.py:216-225 加一个分支。不要去改三个 ask 方法——协议差异应该全部被 client 外壳吸收。

加一个配置段:照 SearchSettings 的模式做四步——定义 Pydantic 模型(app/config.py:39-60)、挂到 AppConfig(app/config.py:174-194)、_load_initial_config 里读段并兜底默认(app/config.py:284-312)、加 property(app/config.py:331-359)。注意 MCP 段的 JSON 双文件模式只在需要兼容外部生态格式时才值得。

与同类对比:MetaGPT 用 YAML + 更重的配置继承体系,LangChain 的多供应商靠 partner package 各自实现,litellm 用统一网关 + router。OpenManus 的"OpenAI 骨架 + 少量手工适配"处在轻量端——代价是 Bedrock 这类协议差异大的后端适配质量粗糙(第 9 节),收益是依赖极少(只依赖 openai + boto3)、代码可全部读完。对"要读懂每一行"的学习场景,这个取舍是对的;对要多供应商负载均衡的生产场景,应该换 litellm 或自建 router。

另一个值得吸收的模式是命名单例做模型路由(第 5 节)。在多 Agent 系统里,"主 Agent 用便宜模型、浏览器用 vision 模型、planner 用强推理模型"是普遍需求,LLM._instances 字典 + 配置继承(第 3 节)用 30 行代码把这个需求做完了,比引入模型路由框架轻得多。

12. 小结与承上启下

这篇的两个单例(Config 进程级、LLM 命名级)、一个合并算法(default+override)、一个协议外壳(Bedrock 同构适配),构成了 OpenManus "一个 toml 走天下"的全部骨架。它的设计哲学和 01 篇的执行引擎一脉相承:用最少的抽象层解决问题,把复杂度留给最值得的地方——所以你会看到它宁可手写 334 行 Bedrock 适配也不引入 litellm,宁可 toml 浅合并也不上配置继承框架。

代价也如实记录在本篇:重试条件背离(app/llm.py:637-643)、ask_tool 静默 None(app/llm.py:737-740)、MULTIMODAL 清单腐化(app/llm.py:35-42)。这些不是"开源项目难免的瑕疵",而是最小抽象策略的固有成本:抽象层薄了,漏出来的毛边就直接暴露在调用方。

下一篇我们下沉到工具生态:BaseTool 如何用 Pydantic 元数据自动生成 OpenAI function calling 的 schema,ToolCollection 如何管理 20+ 工具的生命周期,以及 PythonExecute 为什么宁可起子进程也要 5 秒超时。

关键源码事实:

#事实位置
1Config 双检锁单例,_initialized 防重复加载app/config.py:197-215
2config.toml 缺失静默回退 config.example.tomlapp/config.py:217-226
3LLM 配置 default + 命名覆盖浅合并app/config.py:313-320
4MCP servers 单独从 config/mcp.json 加载app/config.py:148-171, 299-306
5LLM 按 config_name 命名单例(_instances 字典)app/llm.py:174-184
6客户端三分支:azure/aws/默认 AsyncOpenAIapp/llm.py:216-225
7tiktoken 无预设回退 cl100k_base(非 OpenAI 模型计数为估算)app/llm.py:209-214
8图片 token:low=85,high=512px tile × 170 + 85app/llm.py:45-116
9重试条件含 Exception,TokenLimitExceeded 实际被重试 6 次(与注释意图相反)app/llm.py:637-643
10ask_tool 强制 stream=False;无效响应静默 return None(raise 被注释)app/llm.py:731, 737-740
11MULTIMODAL_MODELS 硬编码到 claude-3 早期型号,example 默认模型不在列app/llm.py:35-42
12Bedrock 用同构外壳模拟 OpenAI SDK 属性链,converse 换协议app/bedrock.py:37-58, 195-218
13Bedrock 适配用模块级全局变量传 tool use ID(自注 Tmp solution);init 失败 sys.exit(1)app/bedrock.py:11-13, 44-46
14Daytona VNC 密码默认硬编码 “123456”;browser 默认 disable_security=Trueapp/config.py:122-124, 71-73
Logo

AtomGit AI 社区提供模型库、数据集、Agent、Token等资源

更多推荐