OpenHands Agent Canvas 前端 LLM 默认模型回退机制:LLD-001 规格、实现与测试全景
本篇围绕 OpenHands(Agent Canvas)前端仓库中的规格文件 specs/llm-defaults.md 展开,完整解析 LLD-001 规格:“前端在发起对话时始终显式发送自己选定的默认 LLM 模型”。读完本文,你能掌握该默认模型在 src/services/settings.ts 中的定义方式、在 src/api/agent-server-adapter.ts 中对话发起请求构建链路里的回退逻辑,以及单元/加密载荷两条路径如何被测试用例逐一锁定。
1. 背景:为什么要“前端永不依赖服务端默认模型”
Agent Canvas 作为 OpenHands 的前端,通过 @openhands/typescript-client SDK 与 Python agent-server 通信。启动一次对话时,前端需要把 agent_settings(其中包含 llm.model)发给 agent-server。这里存在两套“默认模型”:
- 前端默认:
DEFAULT_SETTINGS.llm_model,当前值为"openai/gpt-5.6-sol"(见 src/services/settings.ts); - agent-server SDK 自己的默认模型:
gpt-5.5。
风险在于:如果用户从未保存过设置,agent-server 返回的 llm.model 可能缺失或为空。此时若前端把空值原样发出去,agent-server 会悄悄使用它自己的 SDK 默认模型 gpt-5.5 来运行对话——而设置界面显示的却是前端默认模型。界面与实际运行“两张皮”,这是 LLD-001 规格要杜绝的核心问题。AGENTS.md 中的贡献者清单也把这一点写成了硬规则:前端永远必须显式发送模型值,绝不依赖 agent-server SDK 的默认值。
2. LLD-001 规格条目(原文完整继承)
specs/llm-defaults.md 的全文即一条已完成的规格 LLD-001: Frontend always sends its chosen default model,包含四个验收项:
- [x] 当 agent-server 返回缺失或为空的
llm.model(例如用户从未保存过设置)时,前端适配器在发送 conversation-start 请求之前,必须用DEFAULT_SETTINGS.llm_model("openai/gpt-5.6-sol")替换该值。 - [x] 前端绝不依赖 agent-server SDK 自身的默认模型(
gpt-5.5);必须始终发送一个显式的模型值。 - [x] 仅包含空白字符的模型字符串应视为“缺失”,并回退到默认值。
- [x] 当 LLM 设置通过
encryptedAgentSettings下发时(即 conversation-start 的加密载荷路径),同样的守卫逻辑同样生效。
这四条分别对应“普通路径的空值回退”“显式发送契约”“空白串边界”和“加密路径一致性”,下面逐一对应到源码与测试。
3. 默认模型的唯一定义点:DEFAULT_SETTINGS
默认值集中定义在 src/services/settings.ts 中:
export const LATEST_SETTINGS_VERSION = 5;
export const DEFAULT_SETTINGS: Settings = {
llm_model: "openai/gpt-5.6-sol", // 顶层默认模型
llm_base_url: "",
agent: "CodeActAgent",
// ...
agent_settings: {
schema_version: 6,
agent_kind: "openhands",
agent: "CodeActAgent",
llm: {
model: "openai/gpt-5.6-sol", // 嵌套 agent_settings 中的模型与顶层一致
},
// condenser / verification / mcp_config 等其他默认项
},
// ...
};
有两个值得注意的结构事实:
- 默认模型在设置对象中出现两次——顶层
llm_model(展示层使用)和嵌套agent_settings.llm.model(对话发起的实际数据源)。AGENTS.md 的贡献者清单明确指出:要修改默认模型,唯一正确的位置是 src/services/settings.ts 中的DEFAULT_SETTINGS.llm_model,同时同步更新specs/llm-defaults.md清单。 - 回退读取的是
DEFAULT_SETTINGS.llm_model这一个常量,保证“界面显示的默认”与“实际发出的默认”同源。
4. 实现解析:conversation-start 构建链路中的三处关键代码
4.1 明文路径:buildConfiguredOpenHandsAgentSettings 中的模型回退
核心守卫位于 src/api/agent-server-adapter.ts:
function buildConfiguredOpenHandsAgentSettings(
settings: Settings,
runtimeServicesInfo?: RuntimeServicesInfo | null,
query?: string,
): AgentSettingsPayload {
const agentSettings = toRecord(settings.agent_settings);
const llm = toRecord(agentSettings.llm);
llm.model =
typeof llm.model === "string" && llm.model.trim().length > 0
? llm.model
: DEFAULT_SETTINGS.llm_model;
// 与 ACP agent 对齐:开启 token 流式输出
llm.stream = true;
const apiKey = normalizeSecretString(llm.api_key);
// ...api_key / base_url 空白值会被直接删除而不是发送空串
这段代码一次性覆盖了规格的三条验收项:
- 缺失回退:
llm块或llm.model整体不存在时,toRecord得到空对象,三元表达式落入DEFAULT_SETTINGS.llm_model; - 空串回退:
""不满足trim().length > 0,被替换为默认值; - 空白串回退:
" "经trim()后长度为 0,同样被替换——这正是规格第三条“whitespace-only 视为缺失”的实现。
顺带说明,同一函数还完成了 api_key / base_url 的 trim() 归一化与空值剔除(见 src/api/agent-server-adapter.ts),保证发给 agent-server 的 LLM 配置块干净且必然携带一个非空 model。
4.2 加密路径:encryptedAgentSettings 如何覆盖并复用同一守卫
conversation-start 存在第二条设置来源。src/api/agent-server-adapter.ts 中的 buildStartConversationRequestWithEncryptedSettings 会并行拉取设置、密钥与运行时服务信息,其中设置来自 SettingsService.getSettingsForConversation() 返回的加密形态 agent settings:
return buildStartConversationRequest({
...options,
encryptedAgentSettings: agentSettings, // 加密载荷覆盖明文设置
encryptedConversationSettings: conversationSettings,
secretsEncrypted,
customSecrets,
runtimeServicesInfo,
});
而 buildStartConversationRequest 内部(src/api/agent-server-adapter.ts)用一行代码实现了“加密载荷优先”:
const sourceAgentSettings = options.encryptedAgentSettings
? { ...options.settings, agent_settings: options.encryptedAgentSettings }
: options.settings;
也就是说,一旦传入 encryptedAgentSettings,它就整体替换 settings.agent_settings 成为数据源,随后依旧流经 buildConfiguredAgentSettings → buildConfiguredOpenHandsAgentSettings,从而自然复用了 4.1 中同一段模型回退逻辑。这就是规格第四条“加密路径同样受守卫”的落地方式:不写第二份回退代码,而是让两条路径汇入同一个纯函数。
4.3 展示层的对称回退
除了发送侧,展示侧也做了对称处理。toAppConversation 在把 agent-server 返回的会话信息映射为前端模型时(src/api/agent-server-adapter.ts),对 OpenHands 类型会话用 info.agent?.llm?.model ?? DEFAULT_SETTINGS.llm_model 兜底,保证会话卡片上显示的模型不会因服务端字段缺失而悬空(ACP 会话则走独立的 resolveEffectiveAcpModel 解析链)。
5. 测试证据:四种空值形态 + 加密路径逐一锁定
规格的可验证性由 tests/api/agent-server-adapter.test.ts 的参数化用例承担。测试文件用 it.each 枚举了“缺失模型”的全部形态(见 测试文件):
| 用例形态 | 构造的 agent_settings |
期望结果 |
|---|---|---|
| 空字符串 | { llm: { model: "" } } |
回退到 DEFAULT_SETTINGS.llm_model |
| 仅空白 | { llm: { model: " " } } |
回退到 DEFAULT_SETTINGS.llm_model |
缺失 llm 块 |
{ schema_version: 1, agent_kind: "openhands", agent: "CodeActAgent" } |
回退到 DEFAULT_SETTINGS.llm_model |
| 整体为空 | {}(模拟跳过 onboarding 的新用户) |
回退到 DEFAULT_SETTINGS.llm_model |
加密路径另有两组用例,断言当 encryptedAgentSettings 为 { llm: { model: "" } }(携带空模型)或 {}(整体为空)时,最终发出的 llm.model 依然是 DEFAULT_SETTINGS.llm_model(见 测试文件)。测试注释还点明了设计意图:“encryptedAgentSettings 在 conversation start 时覆盖 settings.agent_settings;若加密载荷未设置模型,前端默认值仍必须被显式发送。”
另有一个相邻用例值得注意:tests/api/agent-server-adapter.test.ts 中“nested settings as the source of truth”用例断言了顶层 llm_model: "stale-top-level-model" 不会参与发送——真正发出的是嵌套 agent_settings.llm.model("nested-model"),同时 api_key / base_url 的空白被 trim 掉、stream: true 被强制写入。这佐证了“嵌套设置为唯一事实源、顶层字段仅作展示”的架构分工。
6. 维护契约:改动默认模型时的同步清单
AGENTS.md 将 LLD-001 写进了贡献者必看的清单,形成了一条明确的维护契约:
默认 LLM 模型——
DEFAULT_SETTINGS.llm_model(定义于src/services/settings.ts)是前端规范默认值。src/api/agent-server-adapter.ts中的buildConfiguredOpenHandsAgentSettings在解析出的llm.model缺失、为空或仅空白时总是显式发送该值;前端从不依赖 agent-server SDK 的默认值(gpt-5.5)。若你更改默认模型,必须同时更新src/services/settings.ts中的DEFAULT_SETTINGS.llm_model以及specs/llm-defaults.md中的清单。规格:@spec LLD-001。
由此可以归纳出日常开发中的三条实践要点:
- 单一事实源:修改默认模型只改
DEFAULT_SETTINGS.llm_model一处,所有回退点(发送守卫、toAppConversation展示兜底)自动跟随; - 不要依赖服务端默认:任何新增的“直接调用 agent-server 创建会话”的代码路径,都应复用
buildStartConversationRequest这条构建链,避免绕过模型守卫(仓库还有 src/api/no-direct-agent-server-calls.test.ts 这类约束测试来防止直接调用 SDK 客户端); - 规格与实现互指:
specs/llm-defaults.md的每条[x]都能映射到 4.1–4.2 的源码位置与第 5 节的测试用例,形成“规格 → 实现 → 测试”的三方闭环。
7. 小结
LLD-001 用一个看似简单的规则——“空值/空白值一律回退到前端默认模型,且加密路径同样适用”——解决了 OpenHands 前端与 agent-server 之间“默认模型不一致”的隐患。其工程价值体现在三点:默认值集中在 DEFAULT_SETTINGS.llm_model 单一常量;回退逻辑作为纯函数内嵌于 buildStartConversationRequest 构建链,明文与加密两条设置来源共享同一实现;参数化测试覆盖了空串、纯空白、无 llm 块、整体为空四种形态及加密载荷的对应形态。这套“规格文档 + 单一事实源 + 参数化测试”的组合,也为仓库内其他默认值类行为(如 config/defaults.json 相关配置)提供了可参照的实现范式。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0622
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00