09 · 模型切换与 Provider
把模型选择、工具选择和线程记忆统一进同一条真实工作流,支持多模型和多 provider 切换。
课时资源
一、学习目标
本课所在阶段:第四阶段 · 模型与多模态扩展。
学完这一课后,你应该能够:
- 理解为什么模型选择不能写死在 Agent 里
- 看懂
modelId、toolIds、threadId怎样从前端一路流到后端工作流 - 明白
MemorySaver、bindTools(...)、ToolNode在这一课里分别扮演什么角色 - 知道这一课已经开始接真实模型,而不是继续用教学模板回复
二、问题背景
到了这个阶段,聊天应用已经不能只满足于"能聊天、能记忆、能调工具"。真实项目里你很快会遇到两个新问题:
- 同一个页面可能要切不同模型,比如
qwen-plus、qwen-turbo、gemini-2.5-flash - 同一条消息未必总要绑定全部工具,工具启用范围也应该跟着请求变化
如果没有这一课,后面会越来越难演进:
- 前端看不到当前到底使用了哪个模型
- 后端没法根据
modelId创建不同 provider 的模型实例 - 第八课已经建立的统一工具配置,也还没和模型切换真正合并成一条可运行链路
这一课真正要解决的,不是"做一个下拉框",而是把模型选择、工具选择和线程记忆统一进同一条真实工作流。
三、核心概念
这一课最重要的不是 UI,而是三条输入链路第一次同时进入 Agent:
modelId决定这轮消息到底用哪个模型、哪个 providertoolIds决定这轮消息到底允许绑定哪些工具threadId决定这轮消息要续接哪条历史线程
你可以把它理解成:从这一课开始,聊天工作流真正进入"参数化运行"阶段。工作流不再只依赖消息文本本身,而是同时依赖模型、工具和线程上下文。
这一课相对第 08 课的核心增量
| 维度 | 第 08 课 | 第 09 课 |
|---|---|---|
| 模型来源 | 隐式固定模型 | 显式 modelId |
| provider 适配 | 不对用户暴露 | 开始支持 OpenAI 兼容和 Google provider |
| 工作流缓存 key | 只跟工具组合相关 | 跟 modelId::toolIds 组合相关 |
| 前端状态 | 会话 + 工具选择 | 会话 + 工具选择 + 模型选择 |
| 事件流 | session.start、tool.call、文本事件 | 额外新增 model.selected |
四、整体流程
五、运行过程
1. 前端同时管理模型和工具
这一课里,page.tsx 已经不只维护 selectedToolIds,还新增了:
modelIdactiveModelLabel
所以从这里开始,输入区已经同时控制两类选择:
- 这轮用哪个模型
- 这轮允许哪些工具
2. 请求体开始携带四个关键字段
前端发送请求时,已经变成:
body: JSON.stringify({ message: content, threadId, modelId, toolIds: selectedToolIds })这四个字段分别控制:
message:当前用户输入threadId:续接哪条会话modelId:本轮用哪个模型toolIds:本轮启用哪些工具
3. 服务层先解析模型,再发 model.selected
streamChatResponse 在正式输出文本前,会先发:
yield { event: 'model.selected', data: resolveModel(modelId) };这一步的作用是让前端在第一段文本出现前,就知道当前实际选中的是哪个模型,并把侧边栏 footer 或页面标签及时更新。
4. Agent 按模型和工具组合构建工作流
第八课的缓存 key 只和工具组合有关;第九课升级成:
return `${modelKey}::${toolKey}`;这意味着"同一组工具但不同模型"不再共用一套工作流实例,而是明确区分为不同运行配置。
5. 工作流内部仍然保留记忆和工具调用
这一课并没有抛弃前面两课建立的能力,而是把它们全部保留下来:
- 记忆:继续由
MemorySaver负责 - 工具调用:继续由
bindTools(...) + ToolNode负责 - 模型切换:新增加在最外层模型工厂上
所以这节课本质上是在做能力叠加,而不是推翻前面结构。
六、关键代码解析
app/agent/config/models.config.ts - 定义这一课可选的模型列表,并通过 resolveModel 解析默认模型。
关键代码:
export function resolveModel(modelId?: string) {
return modelOptions.find((option) => option.id === modelId) ?? modelOptions[0];
}代码解析:
前端传来的 modelId 并不一定总是有效,比如:
- 初次加载时还没选
- 某个旧值已经不在当前模型列表里
这一层解析能保证服务端始终回退到一个稳定默认模型,而不是把非法值直接带进模型创建流程。
- 前端可能传一个已删除的模型 ID,或者空字符串
find找不到时,回退到modelOptions[0]作为默认值- 这样模型选择层就有了一层安全兜底,不会因为非法 ID 导致整个请求失败
app/agent/chatbot.ts - 本课最关键的后端入口。它按 modelId 和 toolIds 动态构建工作流,并缓存不同组合对应的 app。
关键代码 1:工作流缓存 key 要同时包含模型和工具
function getWorkflowKey(modelId?: string, toolIds?: string[]) {
const modelKey = modelId || resolveModel().id;
const toolKey = !toolIds || toolIds.length === 0 ? '__no_tools__' : [...new Set(toolIds)].sort().join(',');
return `${modelKey}::${toolKey}`;
}代码解析:
如果这里只按 toolIds 做缓存,那么切换模型时就会错误复用旧模型对应的工作流实例。这一课新增 modelKey,正是为了把"模型差异"提升为工作流配置的一部分。
modelKey默认用当前选择的modelId,没有时用解析出的默认值toolKey对工具 ID 去重、排序,保证组合唯一性- 双重 key 确保不同模型、不同工具组合都有各自的工作流实例
- 切换模型后不会误用之前缓存的旧模型工作流
关键代码 2:动态构建带模型的工作流
function buildChatApp(modelId?: string, toolIds?: string[]) {
const tools = createLangChainTools(toolIds);
const workflow = new StateGraph(MessagesAnnotation)
.addNode('chatbot', async (state) => {
const model = createModel(modelId);
const modelMessages = toModelMessages(state.messages);
const response = tools.length > 0 ? await model.bindTools(tools).invoke(modelMessages) : await model.invoke(modelMessages);
return { messages: [response] };
})
.addNode('tools', new ToolNode(tools))
.addEdge(START, 'chatbot')
.addConditionalEdges('chatbot', shouldContinue, {
tools: 'tools',
[END]: END,
})
.addEdge('tools', 'chatbot');
return workflow.compile({ checkpointer });
}代码解析:
modelId第一次进入工作流构建函数,不再是隐式固定值createModel(modelId)根据模型 ID 创建对应 provider 的模型实例- 工具绑定逻辑保持不变,只是现在工具和模型都成了可配置参数
- 相同的
modelId::toolIds组合会复用已缓存的工作流实例
app/services/chat.service.ts - 负责解析 modelId、toolIds,并在流开始前发出 model.selected 事件。
关键代码:
yield { event: 'model.selected', data: resolveModel(modelId) };代码解析:
前端不能只根据本地 modelId 猜测当前模型名称,因为真正生效的是服务端 resolveModel(modelId) 后的结果。通过事件把最终解析结果推回前端,页面状态和服务端执行状态才会一致。
- 前端选择的
modelId可能是无效值 - 服务端通过
resolveModel确定最终使用的模型 model.selected事件把最终模型的name推回前端- 前端据此更新
activeModelLabel,让用户看到真正生效的模型
app/page.tsx - 维护 modelId、selectedToolIds、activeModelLabel 等状态,并把这些选择发送给后端。
关键代码:
// 用户切换模型
onModelChange={(value) => {
setModelId(value);
const nextModel = modelOptions.find((option) => option.id === value);
if (nextModel) setActiveModelLabel(nextModel.name);
}}
// 发送消息
body: JSON.stringify({ message: content, threadId, modelId, toolIds: selectedToolIds })代码解析:
- 用户在
ModelSelector里切换模型,page.tsx更新modelId - 发送消息时,
modelId和toolIds一起作为请求参数 - 服务端通过
resolveModel确定最终模型,并通过事件推回前端 chatbot.ts用modelId创建对应 provider 的模型实例
app/components/ModelSelector.tsx - 承接模型切换 UI,让模型选择真正进入输入区交互。
app/api/chat/route.ts - 除了聊天和历史恢复,还通过 getChatBootstrap() 为前端返回初始 sessions + tools。
七、常见问题
为什么这一课已经有模型切换,但还保留工具面板?
因为真实项目里模型和工具不是两套彼此独立的系统。它们都会共同影响这轮 Agent 调用。
为什么这里继续用 MemorySaver?
因为这一课的重点不是数据库,而是让"模型选择 + 工具选择 + 会话记忆"三条链路先跑通。MemorySaver 仍然是当前最合适的中间版本。
默认为什么优先是 OpenAI 兼容模式?
因为当前课程环境已经接好了 DashScope 的 OpenAI 兼容端点,这能让你先把真实模型跑起来,再理解 provider 差异。
这一课和下一课多模态上传是什么关系?
这一课解决"模型选择",下一课解决"图片附件"。两者是独立的扩展方向:一个在输入类型上扩展(文本 -> 多模态),一个在模型能力上扩展(单一模型 -> 多模型)。
八、练习题
- 解释
modelId、toolIds、threadId这三个字段分别控制什么。 - 如果你只想让这一轮消息启用
current_time,前端和后端各会发生什么变化? - 为什么工作流缓存 key 不能只看
toolIds,还必须包含modelId? - 说明
model.selected事件和本地setModelId(...)的职责有什么不同。 - 假设要新增一个 Anthropic provider 模型,最少需要修改哪几个文件?
九、总结
这一课真正建立的是:把模型选择、工具选择和会话记忆统一进一条真实工作流。
只要你已经能说清 ChatInput -> page.tsx -> chat.service.ts -> chatbot.ts 这条链路,以及 modelId、toolIds、threadId 分别怎样参与调用,这一课就掌握了。
登录以继续阅读
解锁完整文档、代码示例及更多高级功能。