OpenHarmony 小鸿 AI 开发实战 14:可替换 LLM Provider 的现状与目标边界

OpenHarmony 小鸿 AI 开发实战 14:可替换 LLM Provider 的现状与目标边界
运行 OpenHarmony mini/LiteOS-M 的 WS63 设备并不需要知道服务端接入了哪一家大语言模型。设备上传 Opus服务端完成 ASR 后得到 question模型返回 answer服务端再执行文本门禁和 TTS。只要 WebSocket 消息与设备显示、播放契约不变LLM 本来就应该是服务端内部可以替换的一层。但“设备协议与模型厂商无关”和“后端已经实现可插拔 Provider”不是同一件事。小鸿 AI 当前源码已经把LLM_API_KEY、LLM_BASE_URL、LLM_MODEL、温度和 token 上限做成通用配置也把核心入口改名为request_llm_completion()与llm_answer()DeepSeek 默认变量被保留为兼容回退。这说明配置与上层调用已经部分解耦。与此同时真实 HTTP 调用仍只有一个urllib函数固定发送 OpenAI-compatible Chat Completions 结构代码中没有 Provider 接口、实现类、注册表或按厂商选择策略。本文把已落地部分和目标设计严格分开。先从当前配置读出真实进度当前server.py同时保留 DeepSeek 兼容变量和通用 LLM 变量。通用 key 优先没有设置时回退到DEEPSEEK_API_KEY。通用 base URL 与 model 也以 DeepSeek 对应值为默认值DEEPSEEK_API_KEY os.environ.get(DEEPSEEK_API_KEY, ) DEEPSEEK_BASE_URL env(DEEPSEEK_BASE_URL, https://api.deepseek.com/chat/completions) DEEPSEEK_MODEL env(DEEPSEEK_MODEL, deepseek-chat) LLM_PROVIDER env(LLM_PROVIDER, deepseek) LLM_API_KEY os.environ.get(LLM_API_KEY, ) or DEEPSEEK_API_KEY LLM_BASE_URL env(LLM_BASE_URL, DEEPSEEK_BASE_URL) LLM_MODEL env(LLM_MODEL, DEEPSEEK_MODEL)这段配置已经支持把请求指向另一套 OpenAI-compatible endpoint前提是对方接受相同的 Bearer header、请求字段和响应 JSON。README 也明确说明可以用通用变量切换其他 OpenAI 兼容服务。这里的“可换”是协议兼容范围内的配置替换不等于后端会根据LLM_PROVIDER自动选择不同 SDK、认证方式或响应解析器。LLM_PROVIDER 目前主要是标签不是工厂开关检索当前源码LLM_PROVIDER用于默认配置、失败日志、完成日志、health 中的 provider 字段以及兼容的deepseek布尔值。它没有进入if provider ...的构造逻辑也没有映射到不同调用对象。这意味着如果只把环境变量写成另一个厂商名而不同时提供兼容的 base URL、model 与 key请求行为不会随名称改变。反过来即使LLM_PROVIDER仍写着 deepseek只要通用 base URL 指向另一套兼容服务实际流量也可能已经不是 DeepSeek。当前 health 显示的是配置标签和 key 是否存在不是对远端身份做过可信探测。准确的工程结论应是provider-neutral 的命名与配置入口已经出现transport strategy 仍未抽象。这个中间状态比“完全写死 DeepSeek”更进一步也比“多 Provider 已落地”更早。真正的调用仍集中在一个 direct adapter 函数request_llm_completion()自己构造 payload、Authorization header、TLS context、18 秒超时并解析choices[0].message.content。它没有依赖第三方 SDK这让依赖较少也把 OpenAI-compatible 假设直接写进了函数def request_llm_completion(messages: list[Dict[str, str]], temperature: float) - str: payload { model: LLM_MODEL, messages: messages, temperature: temperature, top_p: LLM_TOP_P, max_tokens: LLM_MAX_TOKENS, } req urllib.request.Request( LLM_BASE_URL, datajson.dumps(payload, ensure_asciiFalse).encode(utf-8), headers{ Content-Type: application/json, Authorization: fBearer {LLM_API_KEY}, }, methodPOST, )后续用urllib.request.urlopen()发出请求捕获 URL、timeout、JSON 与 OS 错误再固定读取 choices 数组。DeepSeek 的 Chat Completions 接口兼容这套结构所以它是当前默认 direct adapter。能使用相同结构的其他服务也可以接入但需要不同 header、消息结构、响应字段、签名算法或 streaming event 的服务当前函数无法仅靠LLM_PROVIDER适配。上层入口已摆脱 deepseek 命名但依赖仍是全局函数语音命令先由answer_question()本地处理普通问题再进入llm_answer()def answer_question(question: str, session: Session) - str: voice_answer voice_command_answer(session, question) if voice_answer is not None: return voice_answer return llm_answer(question, session.dialogue_key)llm_answer()不叫deepseek_answer()这是已经完成的上层命名解耦它负责对话历史、回答模式、表达变化、重复检测、事实复核与最终裁剪。模型请求都通过request_llm_completion()完成所以以后抽取 Provider 时上层业务规则可以继续保留。不过依赖注入还没有形成。测试通过暂时替换模块全局函数server.request_llm_completion来隔离外部网络完成后再恢复。这个 seam 很实用也说明最自然的下一步是把“可替换函数”提升为显式对象依赖或受控 registry而不是让更多厂商分支散落进llm_answer()。deepseek_answer 现在只是兼容包装源码还保留一个旧名字注释已经说明它服务于旧本地测试和脚本def deepseek_answer(question: str) - str: Compatibility wrapper for older local tests and scripts. return llm_answer(question)这个 wrapper 不再实现独立 HTTP 请求也不应该被当成第二个 Provider。迁移时保留短小兼容层可以避免一次性破坏旧脚本等调用方都改用新入口再通过检索和回归证明没有引用后移除。直接删除名字虽然看起来更干净却可能让部署工具或未纳入当前测试的调用方在运行时失败。Provider 不能只返回一段文本从当前llm_answer()看一次用户问题并不总是只调用模型一次。普通请求先生成候选答案与历史答案相似度达到 0.86 时各回答模式都可能先增加要求后重试其他质量问题只在非 knowledge 模式触发这次质量重试。knowledge 模式随后还会独立执行一次低温事实复核即使首答已经带有过长或虚构经历等质量标记也不是由这些标记触发同一条重试分支。Provider 边界至少要可靠支持 messages、temperature、top_p、max_tokens、timeout 和错误归一化。同时上层必须保留模型无关的业务规则设备级短期历史、相似度阈值、产品身份过滤、完整句裁剪与无 key 时的静态回退。这些规则现在位于请求函数之外是良好的分层基础。若迁移时把它们塞进某个厂商 adapter以后切换模型就会同时改变角色质量与设备显示行为回归范围反而扩大。response_variety_smoke.py当前通过替换请求函数记录 messages 和 temperaturedef fake_request(messages, temperature): calls.append((copy.deepcopy(messages), temperature)) return next(answers)本轮重新运行该冒烟通过覆盖重复检测、相似回答重试、两轮设备历史、角色规则、回答模式、独立事实复核与完整句裁剪。它证明当前确定性编排仍然成立但不证明任何真实 LLM endpoint 当前在线。目标接口要明确标成目标不能冒充现有源码下面只是迁移方向当前仓库尚未实现这些类型、方法和 registry# 目标接口示意当前源码尚未实现 class LLMProvider(Protocol): name: str def complete( self, messages: list[dict[str, str]], options: CompletionOptions, ) - str: ...接口的价值不是多写一个类名而是把差异放在正确位置Provider 自己负责 endpoint、认证、请求映射、响应解析和厂商错误上层 orchestration 只关心标准 messages、采样参数、文本结果和统一异常。OpenAI-compatible 实现可以直接承接现有urllib代码DeepSeek 继续作为默认配置以后新增不同协议实现时不需要修改对话历史与质量门禁。目标 factory 也应对未知名称快速失败而不是静默回落到 DeepSeek。静默回落会让日志写着 A、实际请求 B故障与成本都难以追踪。只有显式声明的兼容别名可以回落并应在 health 中同时展示 configured provider 与 active adapter。迁移应先保持旧函数契约再替换内部实现第一步不是删除request_llm_completion()而是为它建立明确输入输出测试并让它委托给默认 Provider。这样现有llm_answer()和response_variety_smoke.py可以继续工作。第二步增加 factory 与配置校验第三步再加入第二个真实 adapter用同一组 contract test 验证成功、空响应、非法 JSON、鉴权失败和超时。迁移过程中至少要固定这些行为question - dialogue history and response mode - provider.complete(messages, options) - similarity and deterministic quality checks - optional retry or independent knowledge review - identity filter and complete-sentence trimming - answer for screen and TTS这段是目标数据流说明不是当前类结构。当前代码对应的是其中 provider.complete 位置仍由模块级request_llm_completion()承担。保持这个对照代码评审就能清楚判断每个迁移提交到底移动了哪条边界。健康检查需要从配置回显升级为实际 adapter 状态当前 health 中的 LLM 信息来自全局变量llm: { enabled: bool(LLM_API_KEY), provider: LLM_PROVIDER, model: LLM_MODEL, temperature: LLM_TEMPERATURE, top_p: LLM_TOP_P, persona: PERSONA_VERSION, persona_answer_chars: PERSONA_ANSWER_MAX_CHARS, device_answer_chars: DEVICE_ANSWER_CHARS, history_turns: DIALOGUE_HISTORY_TURNS, history_ttl_s: DIALOGUE_HISTORY_TTL_S, },enabled只说明字符串形式的 key 非空provider 与 model 只是配置回显。目标架构落地后可以增加 active adapter 名、配置验证状态与最近一次调用结果但不应在 health 中泄露 key、完整 endpoint 查询参数或远端返回正文。探活也不宜每次都发收费模型请求更稳妥的做法是轻量配置状态加受控的独立 smoke。密钥、超时与错误语义必须成为公共契约当前没有 key 时返回预设短句网络、超时、JSON 和 OS 错误被归一为LLMRequestError上层记录错误类型后同样回退。这个行为确保设备不会因为模型服务暂时不可用而无限等待但当前日志粒度无法区分 HTTP 状态、限流、鉴权与服务端异常。Provider 抽象落地时可以引入有限的错误类别例如 configuration、authentication、rate_limit、timeout、invalid_response 和 unavailable并保持面向设备的提示稳定。是否重试、重试几次以及知识复核失败是否保留候选答案应由上层策略决定adapter 不应在内部做不可见的多次调用。密钥继续只从服务器环境进入。设备固件、OTA 响应、文章、截图和公开日志都不能包含真实 LLM key。不同 Provider 需要不同凭据时factory 只接收已解析的配置对象不要把整个进程环境或部署凭据文件传给每个实现。验证顺序要覆盖兼容性而不是只看请求成功迁移完成的最低验证应分四层。第一层是 adapter contract test覆盖 payload、header、响应解析和错误映射第二层是现有回答多样性冒烟确保历史、重试与事实复核次数不变第三层是 HTTP/hajimi/chat与 WebSocket 替身协议测试第四层才是使用受控凭据的真实 provider smoke 与 WS63 端到端回归。需要比较的指标不只是“有回答”还包括首个模型响应耗时、总调用次数、超时回退、答案长度、TTS 是否收到完整句、同一设备历史是否隔离以及切换 Provider 后知识问题是否仍经过独立复核。配置回滚也要可操作保留原 direct adapter 的兼容路径出现异常时通过受控配置恢复不修改设备固件。本轮证据边界与下一步本轮核对了当前server.py、response_variety_smoke.py、protocol_smoke.py、README、部署脚本和技术架构文档并用逐文件 SHA-256 固定版本。protocol_smoke.py与response_variety_smoke.py在禁用字节码写入的本地进程中重新通过两者分别验证协议编排和模型请求之外的确定性规则真实外部请求均被替身或未触发。可以确认的是通用 LLM 配置、通用调用函数名和上层llm_answer()已存在DeepSeek 仍是默认兼容配置当前 transport 是单一 OpenAI-compatible direct adapterdeepseek_answer()只是兼容 wrapper。不能确认也不能宣称的是多 Provider 接口、实现类、策略注册和真实第二家厂商 adapter 已落地。本篇目标接口代码明确标为示意不属于当前源码。下一次真正实施时最小可审查改动应只提取现有传输为OpenAICompatibleProvider让旧函数委托它并让全部现有冒烟保持通过。等这个基线稳定再增加第二个协议不同的 Provider。这样每一步都有真实代码、真实测试和清晰回滚点也不会因为几个环境变量的名字变通用就过早宣布架构已经完成。