从本地 Ollama 到线上多模型 Runtime:接入 DeepSeek / Qwen 的实战分享
本文为作者原创首发于掘金现同步发布到 CSDN。内容整理自AI Mind项目的真实开发过程。GitHubhttps://github.com/HWYD/ai-mind对应代码版本v0.2.1线上体验https://ai.hwyblog.cloud/instant-mindAI Mind 是一个基于 Next.js 持续迭代的 AI Chat 项目项目从本地大模型聊天起步逐步扩展流式协议、工具调用、MCP、Skill Runtime 和 Agent 等能力。如果这篇文章或 AI Mind 项目对你有所帮助也欢迎到 GitHub 给项目点个 Star⭐这会是对我继续整理后续版本复盘很大的鼓励。已经发布上线体验地址https://ai.hwyblog.cloud/本地开发情况这篇文章复盘的是我如何把 AI Mind 从本地 Ollama 单一路径升级成一个本地可切换 Ollama / DeepSeek / Qwen、线上可受控切换 DeepSeek / Qwen 的多模型 Runtime。在 AI 应用刚开始做本地 Demo 时模型调用通常不是最复杂的部分。比如 AI Mind 早期版本里直接用本地 Ollama 跑一个qwen3:8b聊天能通流式输出能通Tool Calling 能接上 Agent 也能跑起来。这个阶段真正重要的是先把聊天运行时、流式协议、工具调用、文档读取和 Agent 主链路跑通。但一旦准备把项目做成线上 Demo问题就变了。本地只需要考虑我要调哪个本地模型 Ollama 服务有没有启动 这个模型能不能流式输出线上则必须考虑模型从哪里来 本地和线上分别允许哪些 Provider 前端能不能切换模型 切换模型会不会绕过服务端边界 密钥放在哪里 Provider 原始错误能不能直接给前端 普通聊天和 /tasklist Agent 能不能共用同一个模型创建入口所以v0.2.1的重点不是继续扩展 Agent也不是继续给/tasklist加更多能力。这一版真正要解决的是AI 应用上线前模型选择、Provider 配置和模型调用入口应该怎么收口。换句话说接入 DeepSeek / Qwen 本身不是难点。真正容易出问题的是多模型接入后如果前端、路由、业务运行时和模型 SDK 各自维护一套模型信息整个系统很快会变得不可控。1. 本地 Ollama 阶段为什么很简单在只有本地 Ollama 的阶段模型调用链路可以非常直接。大概就是聊天请求 - 创建聊天会话 - 创建 Ollama 模型实例 - 进入聊天 / 工具 / Agent 运行时 - 输出流式片段 - 前端聚合展示这个阶段的好处是简单。模型来源固定Provider 固定不需要云模型密钥服务地址通常也是本地地址OLLAMA_BASE_URLhttp://localhost:11434说明一下文章里为了方便阅读会省略项目环境变量的统一前缀。真实项目里会加上AI_MIND_前缀比如这里对应的是AI_MIND_OLLAMA_BASE_URL。在本地阶段业务代码里如果直接依赖 Ollama 模型类短期也能跑。但这个设计有一个隐含前提项目只有一个模型来源。只要模型来源从一个变成多个这个前提就不成立了。比如后面要同时支持本地 Ollama DeepSeek Qwen / 阿里云百炼继续让聊天会话创建逻辑直接知道 Ollama就会带来几个问题普通聊天和/tasklistAgent 可能各自创建模型DeepSeek / Qwen 的密钥读取逻辑可能散落在不同地方前端模型下拉可能和服务端真实白名单不一致模型能力差异比如工具调用、结构化输出、流式输出可能没有统一判断Provider 错误可能直接穿透到前端切换模型时很难确认到底是用户选择生效还是默认配置生效。所以v0.2.1 的第一件事不是马上接云模型而是先把“模型选择”这件事从原来的本地模型名升级成一个受服务端治理的模型选择协议。2. 上线 Demo 后模型选择开始变复杂线上 Demo 和本地开发最大的差异不只是“模型变成云端了”。更关键的是线上环境里模型选择必须有边界。本地开发时开发者可以自己改.env.local自己启动 Ollama自己决定用哪个模型。即使出错也只是本地调试成本。但线上 Demo 面向的是外部访问至少要保证密钥不暴露 前端不能填写任意服务地址 前端不能填写任意 Provider 前端不能填写任意模型名 服务端能控制哪些模型可用 模型不可用时有可理解的错误提示 普通聊天和 /tasklist Agent 的模型来源一致所以这一版没有采用“前端传 Provider 模型名 服务地址”的方式。看起来这样最灵活{provider:qwen,model:qwen3.6-flash,baseURL:...,apiKey:...}但这对一个线上 Demo 来说风险很高。前端一旦能传这些字段就意味着模型调用边界被前端控制了。用户不仅可以选择未授权模型还可能把请求打到任意 Provider 或任意服务地址。所以这一版的原则是前端可以选择模型但只能选择服务端允许它看到的模型。前端只传一个字段modelId其余所有东西都由服务端决定Provider 是谁 真实调用的模型名是什么 是否在当前允许的 Provider 列表里 当前环境是否可用 密钥是否存在 是否支持当前请求类型 是否支持工具调用这也是后面 Model Catalog 和 Provider Runtime 要解决的问题。3. 从options.model到options.modelIdv0.2.1 做了一个破坏性协议迁移options.model - options.modelId这个变化看起来只是改了一个字段名但语义差别很大。旧的model更像“裸模型名”qwen3:8b qwen-plus deepseek-chat问题是裸模型名通常和某个 Provider 的原始命名耦合。比如 Ollama 里的模型名可能是qwen3:8b但这个名字不适合直接暴露成 AI Mind 的前端协议。因为qwen3:8b是 Ollama 的原生格式换到 Qwen 或 DeepSeek 就没有同样语义。所以 v0.2.1 把请求字段改成modelId并统一采用provider/model-key例如ollama/qwen3-8b qwen/qwen3.6-flash qwen/qwen3.6-plus qwen/qwen3.7-plus deepseek/deepseek-v4-flash deepseek/deepseek-v4-pro这里的modelId是 AI Mind 内部稳定选择 ID不等于 Provider 原始模型名。比如前端请求 modelId ollama/qwen3-8b Provider 实际调用 qwen3:8b这样做的好处是前端不直接依赖某个 Provider 的原始模型格式后端可以通过模型目录把modelId解析到真实模型名后续 Provider 模型名调整时可以优先在服务端模型目录里收口请求协议更稳定非法modelId可以在路由入口直接拒绝不偷偷回退默认模型。这里我比较在意最后一点。如果用户选择了一个模型结果服务端发现不可用然后静默回退默认模型用户会以为自己在用 A实际跑的是 B。这对调试和体验都不好。所以这版的策略是非法modelId直接失败不回退默认模型。4. Model Catalog服务端模型白名单事实源有了modelId以后还需要一个地方回答哪些模型可以被选择 每个模型属于哪个 Provider 真实调用名是什么 当前环境能不能用 是否支持聊天 是否支持 /tasklist 是否支持工具调用这个地方就是 Model Catalog。可以把它理解成服务端模型白名单。一个模型条目大概包含这些信息typeModelCatalogItem{id:stringprovider:ollama|deepseek|qwenmodelKey:stringproviderModel:stringlabel:stringenabled:booleanavailableIn:Arraydevelopment|productioncapabilities:{chat:booleantasklist:booleanembedding:booleanstreaming:booleanjsonOutput:booleantoolCalling:boolean}}这里保留字段名是为了方便和实现对应。阅读时可以这样理解idAI Mind 内部稳定选择 ID例如 qwen/qwen3.6-flash modelKey项目内部模型 key例如 qwen3.6-flash providerModelProvider 实际调用的模型名例如 qwen3.6-flash 或 qwen3:8b enabled当前是否启用 availableIn在哪些环境可用 capabilities能力声明这几个字段分开以后前端、服务端协议和 Provider 原始调用名就不会混在一起。比如 Ollama 可以这样写{id:ollama/qwen3-8b,provider:ollama,modelKey:qwen3-8b,providerModel:qwen3:8b,label:qwen3-8b,enabled:true,availableIn:[development],capabilities:{chat:true,tasklist:true,embedding:false,streaming:true,jsonOutput:true,toolCalling:true,},}Qwen 可以这样写{id:qwen/qwen3.6-flash,provider:qwen,modelKey:qwen3.6-flash,providerModel:qwen3.6-flash,label:qwen3.6-flash,enabled:true,availableIn:[development,production],capabilities:{chat:true,tasklist:true,embedding:false,streaming:true,jsonOutput:true,toolCalling:true,},}这样 Model Catalog 就成为模型选择的唯一事实源。前端不能自己维护一份静态模型列表业务运行时也不应该散落读取模型配置。所有模型是否可用都应该先回到服务端模型目录判断。5. 模型列表接口前端只拿公开模型列表既然服务端模型目录是事实源那前端模型选择器应该怎么拿数据v0.2.1 新增了模型列表接口GET /api/ai/models这个接口返回{defaultModelId:stringmodels:PublicChatModel[]}但这里有一个很重要的边界这个接口不是把完整 Model Catalog 直接暴露给前端。前端真正需要的只是typePublicChatModel{id:stringlabel:stringprovider:ollama|deepseek|qwen}它不需要知道Provider 实际模型名 内部模型 key 可用环境 完整能力声明 密钥 服务地址 具体缺失了哪个环境变量 Provider 原始错误这些都应该留在服务端。接口返回模型前会做服务端过滤模型已启用 Provider 在当前允许列表中 当前环境可用 支持聊天 DeepSeek / Qwen 已配置密钥 Ollama 已配置服务地址 不返回 embedding-only 模型这样前端拿到的模型列表天然就是“当前环境可选择模型”。比如本地开发可以出现本地 Ollama DeepSeek Qwen在当前前端实现里这些模型会被收口成“本地模型 / 线上模型”两组展示再配上 Provider 图标而不是直接按 Provider 名称分成三列。线上环境可以通过ALLOWED_PROVIDERSqwen,deepseek从入口屏蔽 Ollama。真实项目里对应的是AI_MIND_ALLOWED_PROVIDERS这里正文使用短名是为了减少阅读负担。这样一来前端不需要知道为什么线上没有 Ollama也不需要自己判断哪些模型该隐藏。前端只负责展示服务端已经允许的模型。6. ModelProvider Runtime不是重造 LangChain项目里已经用了 LangChain那为什么还要做一层 ModelProvider Runtime这个问题很关键。我的理解是LangChain 负责模型调用层。 AI Mind ModelProvider Runtime 负责项目级治理层。LangChain 可以帮我们创建和调用模型比如 Ollama、兼容 OpenAI 协议的模型等。但 AI Mind 自己还需要处理这些问题从环境变量读取哪些配置 modelId 怎么解析 Provider 密钥是否存在 模型是否在白名单里 当前请求类型是否支持 这个模型是否支持工具调用 Provider 错误怎么标准化 普通聊天和 /tasklist Agent 是否共用同一入口这些不是 LangChain 替项目自动决定的。所以 v0.2.1 抽了一层ModelProvider接口interfaceModelProvider{readonlyprovider:AiMindLlmProviderreadonlycapabilities:ModelProviderCapabilitiescreateModel(options:ModelProviderCreateOptions):BaseChatModelnormalizeError(error:unknown):NormalizedProviderError}然后通过统一入口创建模型根据解析后的模型选择结果 - 找到对应 Provider - 创建模型实例 - 返回给聊天运行时或 Agent 运行时这样聊天会话创建逻辑就不再直接依赖 Ollama。原来是创建聊天会话 - 直接创建 Ollama 模型现在变成请求进入路由入口 - 根据 modelId 校验模型是否合法 - 把解析后的模型选择结果传给聊天运行时 - 通过统一入口创建模型实例 - 交给对应 Provider 完成真实模型调用这一步的价值不是“代码更抽象”而是普通聊天、Tool Calling、Reader Skill、Utility Skill、/tasklistAgent 都走同一个模型创建入口。后续无论请求选择 Ollama、DeepSeek 还是 Qwen业务运行时不应该关心具体 SDK。7. 先做 Ollama 等价迁移再接 DeepSeek / Qwen多 Provider 改造最容易踩的坑是一上来就同时做几件事改协议 改模型创建入口 接云模型 改前端选择器这样一旦出问题很难判断到底是哪一层坏了。所以 v0.2.1 的实施顺序是先收口再扩展。第一步先做协议和模型目录modelId Model Catalog 模型列表接口 模型选择解析第二步先把现有 Ollama 链路迁移到 Provider RuntimeOllama 原有调用 - Ollama Provider - 统一模型创建入口 - 聊天会话创建逻辑也就是说在接 DeepSeek / Qwen 之前先让原来的本地 Ollama 在新的 Provider Runtime 下等价跑稳。这个顺序很重要。因为它能验证Provider 抽象是否破坏普通聊天 Tool Calling 是否还正常 Reader Skill / Utility Skill 是否退化 /tasklist Agent 是否仍能走受控链路 legacy / graph 两种 tasklist 编排方式是否不受影响只有 Ollama 等价迁移稳定以后再接 DeepSeek / Qwen。这样 Provider Runtime 的风险就被拆开了先证明抽象层不破坏旧能力 再证明新 Provider 可以接入这个抽象层这比一口气把所有模型都接进来要稳得多。8. DeepSeek / Qwen用兼容 OpenAI 协议的 Provider Base 收口差异DeepSeek 和 Qwen / 阿里云百炼都可以走兼容 OpenAI 的 Chat Completions 接口。所以这一版没有为 DeepSeek 和 Qwen 各写一套完全不同的调用链路而是先抽出一个兼容 OpenAI 协议的 Provider 基础层然后再派生DeepSeek Provider Qwen Provider它们的共同部分包括服务地址 密钥 Provider 实际模型名 temperature 最大输出 token 流式输出 用量信息的 best-effort 读取差异保留在各自 Provider 里Provider 名称 密钥来源 默认服务地址 能力声明 错误标准化 模型白名单DeepSeek 使用DEEPSEEK_API_KEYxxx DEEPSEEK_BASE_URLhttps://api.deepseek.comQwen 使用QWEN_API_KEYxxx QWEN_BASE_URLhttps://dashscope.aliyuncs.com/compatible-mode/v1真实项目里这些变量同样会带上AI_MIND_前缀。我没有使用一个通用的LLM_API_KEYxxx原因是多 Provider 情况下独立密钥更清楚DeepSeek Key 配错了不影响 Qwen Qwen Key 缺失不影响 DeepSeek 本地可以同时配置多个 Provider 错误提示也更明确这也符合服务端治理的思路Provider 差异放在 Provider 层不污染聊天运行时和 Agent 运行时。9. Provider 错误标准化不要把原始错误扔给前端一旦接入云模型错误处理就不能像本地 Demo 那么随意。Provider 可能返回密钥未配置 鉴权失败 余额不足 请求过于频繁 上下文过长 参数不支持 服务超时 服务不可用 未知错误如果直接把底层 SDK 的错误信息扔给前端有几个问题文案不可控可能包含平台内部信息可能暴露 URL、headers、request config 片段用户看不懂不同 Provider 的错误格式不统一。所以 v0.2.1 引入了标准化 Provider 错误。实际项目里大致统一成这些类型typeNormalizedProviderErrorCode|MODEL_PROVIDER_NOT_CONFIGURED|MODEL_PROVIDER_AUTH_FAILED|MODEL_PROVIDER_INSUFFICIENT_BALANCE|MODEL_PROVIDER_RATE_LIMITED|MODEL_PROVIDER_INVALID_REQUEST|MODEL_PROVIDER_TIMEOUT|MODEL_PROVIDER_UNAVAILABLE|MODEL_STREAM_FAILED这里保留英文错误码是因为它们会进入协议和测试适合保持稳定。但展示给用户的文案必须由项目自己控制。比如前端可以展示模型服务未配置请检查服务端环境变量。 模型服务鉴权失败请检查密钥。 模型服务余额不足请检查平台额度。 模型服务响应超时请稍后重试。 模型服务暂时不可用请稍后重试。日志也要脱敏。Provider 原始错误不能直接打印而是走统一 helper 和标准化映射。当前实现里脱敏日志 helper 主要只保留这些安全信息状态码 错误名称 安全摘要预览不打印密钥 headers 请求配置 请求体 完整原始错误对象这部分看起来不如模型选择器直观但对线上 Demo 非常关键。本地 Demo 出错开发者自己看终端就行线上 Demo 出错必须保证前端提示可理解日志可排查同时不泄露敏感信息。10. 这版没有改变什么v0.2.1 虽然接入了多模型 Runtime但它没有扩大/tasklistAgent 的能力边界。之前 v0.2.0 已经完成了 Tasklist Agent 的 Graph 编排迁移。v0.2.1 不继续做新的 Agent 类型 自由工具调用 模型自由读取资源 自动写入 docs/tasklists HITL / pause / resume replay / time travel 多 Provider 自动回退模型选择只影响这次请求调用哪个模型不影响Agent 能不能读取资源 Agent 能不能调用工具 Agent 能不能写文件 Agent 能不能突破 Step 限制 /tasklist runtime 是 legacy 还是 graph也就是说TASKLIST_AGENT_RUNTIME 控制 Agent 编排方式 modelId 控制当前请求的模型来源真实项目里对应的是AI_MIND_TASKLIST_AGENT_RUNTIME。这两条线是独立的。比如/tasklist graph runtime qwen/qwen3.6-flash /tasklist graph runtime deepseek/deepseek-v4-pro /tasklist legacy runtime ollama/qwen3-8b这些组合里模型可以变但 Agent 权限边界不变。这一点对 AI 应用很重要。如果模型切换会顺便改变 Agent 权限那系统就会很难理解。v0.2.1 的目标不是让模型决定能力而是让模型成为受控 Runtime 下的一个可替换执行依赖。11. 总结多模型不是多接几个 API做完 v0.2.1 后我对“AI 应用接入多个模型”这件事的感受更明确了。它不是简单地多写几个 SDK 调用Ollama 调一次 DeepSeek 调一次 Qwen 调一次真正需要收住的是这些边界前端只能传 modelId Model Catalog 是服务端事实源 模型列表接口只暴露公开模型信息 路由入口先校验 modelId Provider Runtime 统一创建模型 普通聊天和 /tasklist Agent 共用模型入口 密钥只在服务端读取 Provider 原始错误不能进入前端和日志 模型切换不改变 Agent 权限如果用一句话总结 v0.2.1这版不是让 Agent 更自由而是让 AI Mind 从本地 Ollama 单一路径升级成一个本地和线上都能受控选择模型的多模型 Runtime。这也是我觉得这一版最有价值的地方。它不追求一次性做成完整商业化平台也不做用户自带密钥、复杂计费面板、多 Provider 自动回退这些更复杂的能力。它先把最基础的线上模型选择边界收住谁能选模型 能选哪些模型 选择结果由谁校验 真实 Provider 配置在哪里 错误怎么返回 普通聊天和 Agent 是否共用入口这些问题解决后后续再继续做更完整的线上化能力才有更稳的基础。项目地址 GitHubhttps://github.com/HWYD/ai-mind 线上体验https://ai.hwyblog.cloud/instant-mind如果这篇文章或者 AI Mind 项目对你有所帮助也欢迎给项目点个 Star⭐。你的支持会是我持续更新这个系列、继续整理项目实现过程和设计复盘的很大动力。