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, data=json.dumps(payload, ensure_ascii=False).encode("utf-8"), headers={ "Content-Type": "application/json", "Authorization": f"Bearer {LLM_API_KEY}", }, method="POST", )后续用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 和 temperature:
def 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 adapter;deepseek_answer()只是兼容 wrapper。不能确认也不能宣称的是:多 Provider 接口、实现类、策略注册和真实第二家厂商 adapter 已落地。本篇目标接口代码明确标为示意,不属于当前源码。
下一次真正实施时,最小可审查改动应只提取现有传输为OpenAICompatibleProvider,让旧函数委托它,并让全部现有冒烟保持通过。等这个基线稳定,再增加第二个协议不同的 Provider。这样每一步都有真实代码、真实测试和清晰回滚点,也不会因为几个环境变量的名字变通用,就过早宣布架构已经完成。