本地大模型接入:火山方舟 Response API 双路由兼容
把本地应用接入大模型,从来不是"调一个 API"那么简单。当你的业务同时需要 OpenAI 兼容的 Chat 形态、又要吃下方舟 Response API 的显式缓存红利时,一套清晰的双路由设计能帮你少踩很多坑。
一、写在前面:为什么要"双路由"
1.1 从"单一路径"说起
本地应用接大模型,最省事的做法往往是统一走一条 OpenAI 兼容的 chat.completions 路径:传 model、messages、工具,再带上一些额外参数。这套方式生态成熟、接入成本低,绝大多数厂商都兼容。
但随着业务深入,问题也慢慢浮现:
- 多轮对话靠"全量重放":每一轮都把历史
messages重新拼一遍发到上游,窗口越长、成本越高、首字延迟越明显; - 显式缓存能力受限:尤其方舟上部分模型(如 DeepSeek 系接入点)的显式 Session 缓存,需要走 Response API 的
caching+previous_response_id链式调用,普通 Chat 面并不承载同一套能力; - 换提供商就换一套适配:今天接方舟、明天接 DeepSeek 官网、后天接 OpenRouter,每换一家都要动调用层,维护成本直线上升。
1.2 双路由的思路
"双路由"并不是推翻既有方案,而是在保留现有 POST /llm/chat(OpenAI 兼容 chat.completions)的同时,新增一条 POST /llm/ark/response 路径,专门承接方舟 Response API 的能力。两条路径各司其职、可插拔、可灰度,协议差异尽量收敛在网关层,避免在业务各处散落"裸 if provider"。
简单说:Chat 路径管存量,Response 路径管增量——新增的显式缓存、链式瘦体能力走第二条路,存量业务不动。
二、概念对齐:从 Chat 到 Response
动手前,先把几个关键概念对齐,否则后面看请求体会一头雾水。
2.1 chat.completions 与 Response API 的差异
两者是不同的 API 面:响应对象结构不同、流式事件名不同,方舟官方文档也把两者分册说明。所谓"兼容",指的是本系统内部统一了领域事件,而不是"HTTP 请求体与 OpenAI 100% 同构"。
这也解释了为什么不能简单在 extra_body 里塞一个 caching——Response 的 caching、previous_response_id、流式分块都属于 Response 资源模型,与 messages[] 的 Chat 并不是同一个 JSON 面。硬塞参数也许能"凑巧跑通",但一旦排障就会跟官方行为对不上。
2.2 隐式缓存 vs 显式缓存
- 隐式缓存:对指定模型、在 Chat/Batch 等路径上的自动前缀优化,对显式 Session 链意义有限,且方舟 Responses 文档中表述为不支持隐式缓存;
- 显式缓存:需开通"推理(缓存)定价",并在请求中显式携带
caching,分为 Session 与前缀等模式。
2.3 Session 与 previous_response_id 链式请求
显式 Session 缓存的核心是链式:状态以 response id 为主,多轮调用通过 previous_response_id 串联,而不是每轮把整窗 messages 全量重放。配合 caching,能把"每轮重放全量历史"变成"本轮一条 input + 链 id",在成本与延迟上都有明显收益。
三、整体架构:公网双路由设计
3.1 两条路径的职责
| 路径 | 语义 | 请求体 | 实现 |
|---|---|---|---|
POST /llm/chat |
OpenAI 兼容 chat.completions |
现有 ChatBody |
chat_run.py(run_chat_sync / iter_stream_events) |
POST /llm/ark/response |
方舟 Response API | 单独的 ArkResponseBody |
OpenAI SDK client.responses + 流式映射 |
两条 handler 不走"单入口 + 工厂"的多态,而是各占一条 HTTP 路径。这么做的好处是:HTTP 路径与官方 Response API 文档一一对应,排障时"路径即语义",也避免了在单个 ChatBody 上叠床架屋。
3.2 协议差异收敛在公网
关键的取舍是:把协议差异主要收敛在公网(client.responses、映射、持久化),柜端只掌握本网关定义的 JSON 子集(ArkResponseBody),不直接掌握方舟的 SDK 与域名。这样:
- 柜端不必直连方舟,安全与维护边界清晰;
- 对桌面/SSE 的对外语义保持与既有 step 一致(同构领域事件);
- 差异仅在于"从哪条路径进入同一条'领域事件 → SSE 行'出口管线"。
3.3 路径语义、provider 与 task 角色
POST /llm/chat:task仅用于解析provider/model;POST /llm/ark/response:task仍解析同一份config.yaml里的model等,但另须满足 task 类属校验——不是任意 task 都能打这条路径。
关键设计:不在 config.yaml 为每个 task 新增 backend 键。 路由语义由 HTTP path 表达,task 在配置里仍只提供 provider / model / thinking 等,与现网一致。公网对 /llm/ark/response 单独做允许/禁止的 task 类属校验,防止错配。
3.4 task 与路径的允许关系
| 类属 / 示例 task | 现网 HTTP | 与 /llm/ark/response 关系 |
|---|---|---|
纯文本对话(doc_chat、asset_chat 等) |
POST /llm/chat 为主,灰度可切 |
可纳入允许名单 |
markitdown_vision 等视觉类 |
POST /llm/vision |
一般禁止(除非另子方案) |
default_embedding |
POST /llm/embeddings |
禁止(嵌入非 Response 对话面) |
diagram_image |
POST /llm/images |
禁止(生图非本路由范畴) |
生码规则:Ark 路径的 handler 解析 task 后,若属于禁止类 → 返回 4xx + 可诊断的 code;属于允许名单 → 继续 client.responses。即使柜端误配,公网也会拒绝,不依赖柜端自洽。
四、会话状态与持久化:公网真源 + 柜端镜像
4.1 谁是真源?
| 路径 / 链形态 | 主真相 | 建议持久化字段 |
|---|---|---|
/llm/chat(仅 Chat) |
拼接后的 messages |
历史条 + trim 规则 |
/llm/ark/response(Ark 链) |
response 链 |
last_response_id;可选镜像 messages 仅用于展示/审计 |
核心原则:链状态以公网为权威源,柜端只作镜像缓存。 柜端的 last_response_id 缓存是为了减少往返、提升体验,不是第二真源——两者冲突时以公网已持久化的值为准。
4.2 持久化主键
Ark 链状态(last_response_id 及官方要求的附属字段,如 expire_at)以公网可写存储为准。主键建议二选一:
- 方案 A:
instance_id+session_key(session_key与柜端业务会话对齐); - 方案 B:
instance_id+user_id+obj_id(文档)+thread_id。
加载顺序:先按会话 id 读 state → 若本会话为 Ark 链则走 /llm/ark/response,否则走 Chat。序列化推荐 discriminated JSON(如 kind: "llm_chat" | "ark_response_chain"),避免多态混乱。
4.3 local_app:瘦请求与配置
- 本地历史不废:UI 展示、多轮
tool_calls循环所需历史仍在本地维护,不是"全删本地、只信公网"; - 对公网的 URL:本地按配置选用
/llm/chat或/llm/ark/response,未配置时默认/llm/chat(保持现网,灰度才显式切 Ark); - 瘦体请求:走 Ark 时发必要
input/ 链字段 + 业务会话键,不必每轮重放全量messages。
4.4 两条路径的上游组包差异
现网 Chat 以 build_messages 为核心,把 system + 历史 + 当前 user 拼成 messages[],再做窗口裁剪。换用 Ark 路径时,这一套不能直接当唯一真相:
- Chat 的上游主真相是
messages[];Ark 的主真相是previous_response_id+ 本轮input; - 窗口裁剪在 Chat 上解决"
messages[]过长",在 Ark 上须重新定义问题——本轮input里允许夹带多少非链式材料(pinned 文档块、用户本句、工具结果摘要),由产品 + 官方 Responses 约束单独设计; - 工具多轮:外层"本地 while、公网单跳"可保留,但 Ark 分支的每一跳要按 Responses 文档组包,不等价于在
messages里堆叠与 Chat 完全相同的块结构。
4.5 重启与断链
柜端进程重启后,若无可信本地缓存,需二选一:① 调公网只读 API 拉取权威 last_response_id;② 首跳不带 previous_response_id 开新链(接受与上一进程断链)。推荐在验收中覆盖重启场景。
五、请求体与流式:ArkResponseBody 契约
5.1 ArkResponseBody 字段分层
ArkResponseBody 是单独的 Pydantic 模型,不复用 ChatBody 全字段。字段分两层理解:
- 网关层字段:路由、业务会话键、鉴权等;
- 上游层字段:
model、本轮input、previous_response_id、caching等透传/映射到方舟 Response 的参数。
这样既保持了网关自身语义,又与方舟上游解耦。
5.2 Session caching 与 instructions 互斥
按官方语义,显式 Session 链与部分指令型字段存在互斥约束(如 caching 开启后与 instructions 的取舍),需要在组包时做默认开关与校验,避免同时启用导致语义冲突。细节以官方《上下文缓存》文档为准。
5.3 流式末包与响应体回传
- Ark 路径的流式输出,经
map_response_stream_to_domain_events映射为与 Chat 流同外壳的领域事件,对桌面保持 step08 一致的 SSE; - 每轮 handler 在调 SDK 前以存储为准组
previous_response_id,成功写库后再向柜端 SSE/末包回传最新last_response_id; - 柜端从领域事件或末包解析该 id,写入内存/SQLite,供下一轮组包、减少往返。
闭环语义:链指针的"可恢复、可排障"在"Ark 路径 + 公网存储 + 回传"内成立,柜端缓存只是性能与 UX 优化。
六、多模型路由与生产实践
双路由解决了"Chat 与 Response 两条上游形态并存",而多模型路由解决的是"多个模型怎么选、怎么容灾"。两者可以叠加使用。
6.1 路由策略
在单实例接入多个模型时,可按规则将请求分发到不同模型:
- 负载均衡:按权重分散到多个同类模型,平摊调用压力,适合高并发或成本分摊;
- 性能择优:基于各模型近期首 Token 时延(TTFT),自动路由到当前响应最快的模型,对流式"首字等待"敏感的体验很友好;
- 主备容灾:为核心服务配置备用模型,主模型故障或超时自动切换,保障业务连续性。
注意:模型路由属于 OpenAI 兼容协议下的网关能力,协议透传方式不经过路由逻辑;且仅在同一调用类型的模型之间生效(文本生成与图像生成独立调度)。
6.2 统一适配层与混合路由
更通用的做法是构建统一模型注册与适配层(Adapter),屏蔽 OpenAI、Azure、vLLM 等异构接口差异,为上层提供一致调用契约,解耦业务逻辑与模型厂商。
路由引擎可采取混合策略:先用轻量级关键词规则快速过滤(如 function → 代码模型),再对模糊意图启用向量相似度匹配,兼顾低延迟与语义准确性。
6.3 生产级优化
- 动态熔断:延迟/错误率超阈值自动降权;
- Token 长度分级路由:短输入走小模型降本;
- 批量合并与预热:综合降低运营成本并保障 SLA。
这些与双路由的"可插拔"精神一脉相承:不要让业务被单一模型、单一路径绑架。
七、实施步骤与代码落点
7.1 分阶段实施建议
- 阶段一:新增
POST /llm/ark/response路由 +ArkResponseBody,打通单轮非流式调用; - 阶段二:接入流式 + 领域事件映射,保证对桌面 SSE 语义不变;
- 阶段三:接入链状态持久化(公网真源)与柜端镜像缓存,实现多轮瘦体;
- 阶段四:灰度切换部分纯文本 task 到 Ark 路径,做成本/延迟对比;
- 阶段五:叠加多模型路由与熔断,形成完整的生产体系。
7.2 关键模块与函数边界
- 公网:
load_state→client.responses.create→save_state→ 映射 SSE; - 柜端:
build_ark_request_body(优先读本地镜像 id)→ HTTPS POST → 解析末包回传; - 建议抽取小的共享模块复用:同一套鉴权、
_resolve_task_model、usage/计费/探针、最终流式映射与 SSE 写出。
7.3 风险与缓解
| 风险 | 缓解 |
|---|---|
| 链状态与柜端缓存冲突 | 以公网为真源,柜端仅镜像;重启走拉取或开新链 |
| 工具多轮在 Ark 面不支持 | 本轮采用严格失败策略,不静默回落 Chat,目标态为工具多轮保持 Ark |
| 误把整窗 messages 当 Response input | 显式区分链式 input 与本地历史,按官方 Responses 语义组包 |
| 灰度切换造成断链 | 切换视为新链,须清对端 state,避免无声明混用 |
7.4 验收要点
- 纯文本 task 走两条路径均正常,SSE 对桌面语义不变;
- Ark 链多轮只发瘦体请求,
last_response_id正确回传与恢复; - 禁止类 task 打 Ark 路径返回 4xx;
- 重启场景的断链处理符合预期;
- 缓存相关 token 的 usage 统计可对比成本。
八、小结
本地大模型接入,核心不是"选哪家 API",而是把差异管理好。双路由方案的精髓在于:
- 路径即语义——Chat 与 Response 各占一条 HTTP 路径,协议差异收敛在网关,排障清晰;
- 公网真源 + 柜端镜像——链状态权威、可恢复,柜端只做体验优化;
- 瘦体链式请求——用
previous_response_id替代全量重放,把显式缓存的红利真正吃进来; - 可插拔、可灰度——存量不动、增量渐进,叠加多模型路由与熔断后,业务对单点依赖的免疫力大幅提升。
当你下一次需要在本地应用里接入方舟,或者要把业务从 Chat 迁到 Response 时,不妨先画好这两条路由——多一条路径,多一份从容。