chatkit 适配器协议 v1
本文是「任何后端怎么接进 chatkit」的规范。类型定义在
@volcengine/chatkit/core, 可执行的版本是随包发布的一致性套件runAdapterConformance(@volcengine/chatkit/testing): 下文每条带 【套件】 标记的规则,都有一条对应的断言。一致性套件验证通用行为,不能替代真实后端的协议、并发与恢复联调。
1. 三个角色
- Adapter:唯一认识 wire 协议的地方。把后端的帧翻译成事实,把 Core 的命令翻译成请求。
- Core:协议无关、零依赖。准入事实、推进 run 状态机、发布冻结的快照。它不发请求、不猜语义。
- UI:只读快照、只画。不认识任何协议,也不知道自己接的是哪个 Adapter。
2. Adapter 契约
公开接口及完整调用规则见 Adapter 与会话 API 契约。
创建配置与 runtime 契约
接入方把能力函数传给工厂,工厂生成 ChatRuntimeAdapter。相同业务动作同名,但两层参数不同:Core 提供统一 input,Adapter 编码成 body 后调用接入函数;接入函数返回原始响应,Adapter 解码并向 Core 提交事实。
不需要自行实现 start 或持有 ingress;只有直接实现底层 ChatRuntimeAdapter 才会涉及它们。底层 run/regenerate/edit 返回 Promise
能力函数完整约定
普通能力回调不接收 signal。SDK 在释放运行时时取消 Response 的 reader,或调用异步迭代器的 return();自定义事件流必须在 return() 中移除监听并结束 pending next(),不要关闭其他会话共享的连接。回调返回前的请求超时由应用自己的网络客户端管理。资源释放不请求后端停止。
未提供的能力不挂到 adapter,UI 按有效能力显示执行操作。historyPaging 表示允许历史翻页,与独立方法无关;唯一 history 方法负责第一页与后续页,是否还有内容由 hasMore/cursor 决定。
HTTP 失败和抛错表示调用未完成;受理成功不等于执行完成。流关闭不能推断 completed。OpenAI 工具循环可以在同一 runId 下多次调用 run。
3. 事实
start 拿到的 RuntimeIngress 共 12 个成员,按职责分三类(事实 emitter 7 个均返回 FactResult):
事实 emitter(7)
run 级标注与校准(2)
被动轮登记 / 内容扩展 / 连接观测(3)
每条事实都带 eventId(run 内唯一,用于去重)、sessionId、runId,可选 sourceSequence(单调递增)与 position(续流游标)。
annotateRun({ sessionId, runId, attributes }) 只影响事件:之后这一轮的事件(run_settled、tool_call_settled…)的
attributes 都带上这些属性,同名覆盖接入方在发送时给的。它不进快照、不去重,run 不存在时拒收(run-not-found)。
适合只有适配器知道的维度,比如这一轮实际用的模型、路由到的 Agent。属性只收扁平的基础类型,见 EVENTS.md。
3.1 终态只由远端结论触发
completed / failed / interrupted 只能来自协议的明确结论:SSE 的结束帧、JSON-RPC 的 error、任务的终态状态。
连接断开只改变连接状态,远端结果保持未知;有历史或重放能力时尝试恢复,否则保留已经收到的内容并允许请求后端停止,不伪造失败或完成。
终态之后同一个 run 的任何事实都会被拒收(terminal-sealed)。
3.2 准入
Core 拒收事实时给出原因(FactRejectReason,共 13 项)。其中 duplicate-event 与 no-change 是协议允许的幂等,其余都说明
Adapter 违反了契约:wrong-session、missing-event-id、run-not-found、source-sequence-regressed、terminal-sealed、
output-sealed、active-work、missing-work-item-id、invalid-transition、runtime-disposed、invalid-message。
不合规的消息(invalid-message)整条丢掉,不让界面去猜。规则:
-
实时消息、发送时的本地投影、宿主的历史改写:这一条不进快照,其余照常。
-
打开会话的历史、翻页:整批拒收,
history.refresh()/loadMore()抛出InvalidMessageError。放宽成跳过这一条, 格式问题就会被掩盖成「少了几条消息」。 -
每次都经
ChatCoreRuntime的onInvalidMessage上报InvalidMessageError(带rule、messageId、partId、source, 不带消息内容),缺省console.error;useChatRuntime同时交给onError。这是适配器的 bug,界面上不提示。 -
【套件】 文本、工具、失败三种场景里,除幂等之外没有事实被拒收(
invalid-message不算幂等)。
4. 消息与 part
4.1 id 规则
- 消息 id 在会话内唯一;part id 在消息内唯一。违反时整条消息(或整份历史)被拒收。
- id 必须由协议内容决定,不能由到达顺序、时间或随机数决定:同一段内容无论从实时流还是从历史接口来,id 都一样。 这是「刷新不闪、不重复」的前提:id 不一致时,界面会把同一段话渲染两遍。
- part id 按「一段」划分:工具调用、思考、正文之间的边界开启新的一段,新段用新 id。沿用旧 id 会被 Core 按 id 合并回前面那段。
4.2 用户输入先上屏
run 的第一件事是让用户输入出现在快照里:要么立刻 emitMessage 一条 user 消息,要么实现
projectAcceptedInput,让 Core 在派发时就投影出来。不能等远端回答。
- 【套件】 用户消息在助手回答之前出现在快照里。
4.3 多模态输入与资源位置
内置适配器使用统一 ChatInput:有序 content 记录文字、结构化引用及 attachment 出现位置;attachments 按稳定资源 ID 提供完整 AttachmentResource。每次出现有独立 part ID 和 inline/separate placement,上传来源不决定展示位置。
MediaPart保留 attachmentId、placement、name、mimeType、size、source;source 为 URL、内联 base64 或接入方可解释的稳定 resource.reference。- 输入受理前规范化:省略的 part ID 补齐一次,重复 ID、冲突资源和失效引用拒绝;未引用资源清理。草稿生成的位置 ID 随消息持久化,历史投影不重新分配。
- 协议编码先遍历正文,再按附件条顺序追加 separate 位置;同一资源多处引用分别编码,上传与来源解析按资源复用。
- 按消息保存
{ schemaVersion: 1, input };首次展示、历史、编辑和重发沿用原资源与位置。仅保存模型 wire 消息无法保证布局恢复,参见多模态附件与存储。
5. 错误:RunError
- run 失败(
emitFailed.error、RunView.error、RunSettledEvent.error)和工具调用失败(ToolCallPart.error)都用它。 code用协议自己的错误码,不要翻译。界面和埋点按code分流,按message展示。- Core 在准入时用
toRunError兜底:不合规的值会被收成code: 'UNKNOWN',原值放进cause。这是给不走类型检查的 JS 接入方留的安全网,不是正常路径。 - 【套件】 失败场景里 run 以
failed收口,error是合规的RunError,且code不是兜底的UNKNOWN。
6. 工具调用
- 生命周期:先
emitMessage一个没有result的 part(运行中),拿到结果后整条替换成带result的版本。 status可以不给:getToolCallStatus会按有无result推断(没有是running,有是completed)。 只有协议给出了更多信息(失败、取消)时才需要显式写。result必须区分「没有结果」(undefined)和「结果为空」(null),否则要么永远转圈,要么空结果永远不出运行态。- 【套件】 工具场景里出现带
toolCallId的 tool-call part;run 以completed收口时,没有仍在运行的工具。
7. 结构化数据:DataPart
kind形如acme.weather-card:小写字母、数字、连字符,至少两段,点号分隔(isValidDataKind)。不合规的整条消息拒收。chatkit.前缀保留给本 SDK 自己,第三方不要用。- 界面按
kind查renderers.parts[kind]渲染;没注册的 kind 不渲染,不会把 JSON 漏给用户。
7.1 会话事件
既不是用户说的、也不是助手说的内容(上下文已压缩、日期分隔线、已切换模型、系统告警)是会话事件:一条
role: 'system' 的消息,里面放带 kind 的 data part。它和普通消息一样经 emitMessage 进入 Core,位置由到达顺序决定
(在回答开始之前发,就排在用户消息与回答之间);宿主也可以用 runtime.external.history.mutate 插入。
- 界面用中性外壳画它(无头像、无操作条),内容按
kind查renderers.events[kind]。 内置chatkit.notice(通用提示条:{ title, description?, details?, tone?, actions? })与chatkit.date-divider({ label? , timestamp? });system 消息里的纯文本按一条只有标题的通用提示展示。 - 会话事件不进入会话导航、不参与复制与反馈、不计入「最后一条消息」的判断。
- 属于某一条回答的结构化内容(文件卡、授权卡)不是会话事件,放在助手消息里。判断标准:删掉它,前后两条消息的语义变不变。
8. 历史
历史只从后端来,前端不落盘。Adapter 实现 history(可选 history 翻页);没实现就没有历史,刷新页面后会话清空。
内置适配器在接入方给了 history 函数时才有这两个方法,后端要返回的格式见 BACKEND.md 第五节。
- 失败原因、中断原因、耗时都在
runSnapshots里,重载后失败卡片才能还原。返回的消息同样要过 Core 的消息校验。 activeRun:历史里有一轮还在进行时给。runtime.history.refresh()之后调runtime.history.resumeActiveRun(), Core 在适配器支持续接、这里又给了事件编号、这一轮 Core 还不知道时发起续接(resume的from就是这个事件编号); 否则什么都不做。界面的会话层(useChatSessions/useChatRuntime)在打开会话时已经这样做了。- 适配器要保证续接回来的内容接在历史里已有的那条回答后面:消息 id、part id 沿用历史里的。
8.1 同构
历史接口给出的消息,必须与实时链路给出的同构:同样的消息 id、同样的 part id 与类型、同样的可见内容。
run 落终态后 Core 会把 baseline 标成 reconciliationRequired,接入方随时可能用历史刷新来校准(切回会话、重连后)。
不同构的后果是刷新那一刻界面闪烁或内容重复。
- 【套件】 有历史能力时:跑完一轮再
refresh,助手消息的 id、part id、类型与内容不变。 - 【套件】 无历史能力时:落定后的消息经 JSON 往返后不变。
9. 中止
- 请求后端停止,继续接收输出;终态事件或历史终态确认后才结束运行。
- 没有
abort能力:不能停止已派发的运行;排队消息仍可取消。 - 【套件】 运行中 abort 后 run 以
interrupted收口,之后没有活跃 run。
10. 会话与附件类型
这几项与具体某一轮无关,界面的会话层直接调用适配器,不经过 Runtime 的命令。
10.1 一个实例,多个会话
整个应用只创建一个适配器实例,每个打开的会话各有一个 Runtime。createSessionAdapters(adapter) 负责分发:
- 适配器的
start只被调用一次,拿到的 ingress 会按事实里的sessionId把数据交给对应的 Runtime; 最后一个 Runtime 释放时才调用start返回的断开函数。 - 适配器因此不能把会话 id 记在实例上:请求地址、续流位置、翻译器等与会话相关的状态按
request.sessionId分开存。 - 发给没有打开的会话的事实以
wrong-session拒收。 - 用
useChatSessions/useChatRuntime时界面层已经这样做了,接入方不需要自己调用。
10.2 外部会话目录
SessionInfo 仅描述会话摘要。创建、查询、改名、删除、fork 由外部业务接口实现;adapter 不提供 sessions 操作。useChatSessions({ adapter, activeSessionId }) 按受控 ID 打开运行时,actions.open/release 只管理本地资源。SessionList 接收外部数组与回调。
10.3 重新生成、编辑后重发
编辑后重发与回复后重试统一覆盖当前主线历史:edit 使用新输入,regenerate(重新生成)复用原始输入。两者均替换目标轮次及之后的消息与运行记录,保留之前的内容;旧内容不参与后续上下文,也不能从刷新或翻页的历史中重新出现。后端与 SDK 必须执行同一覆盖范围,旧版本只能独立于当前主线保存。
runtime.regenerate({ runId? }):目标轮(缺省是最后一轮)及其之后的内容作废,用目标轮当初的输入再执行一次。 Core 在一次命令内移除这些消息与 run,排一轮新的 run,派发时调regenerate而不是run。runtime.edit({ runId, input }):同上,但用新输入,调edit。- 请求里的
runId是承接产出的新一轮,targetRunId是被重做的那一轮;messages是目标轮之前的会话消息。 之后的事实都记在新的runId上,语义与run完全一样(返回false是拒绝,抛错是本地 IO 异常)。 - 不受理的情况:有 run 在跑或在排队、适配器没实现、目标轮不存在、重新生成时目标轮的输入未知(从历史里读出来的轮次
没有输入)。适配器返回
false或在受理前抛错时,Core 把作废的内容原样放回。 - 后端自己存会话内容的,要在这两个方法里把目标轮及之后的内容一并作废,否则下次读历史会把它们带回来。 做不到(服务端会话不能回退)就不要实现,界面上对应的按钮不显示。
- 两个命令都经过命令中间件,事件沿用
send_attempt/send_result,载荷里的via标明来源,见 EVENTS.md。 - 【套件】(harness 声明
regenerate时)重新生成后原来那一轮的消息不在快照里,新一轮以completed收口, 带着一条用户消息与回答,消息 id 不重复。
10.4 附件类型
acceptedFileTypes同<input accept>的写法(如['image/*', 'application/pdf']);不给表示不限制。 声明了就要说到做到:不支持的文件不能悄悄丢掉,仍然收到时以RunError失败收口。
10.5 保留的名字
chatkit.*数据命名空间:保留给 SDK 内置的数据块(目前是chatkit.notice、chatkit.date-divider), 第三方不要用。
11. 接入自检
仓库里的各个适配器都接了这套断言,可以直接照抄它们的 *.conformance.test.ts。
12. 连接恢复与远端取消
sessionId 标识会话,runId 标识一次执行,eventId 标识后端事件游标。RunView.connection 独立于执行状态。连接中断不能据此发出 emitFailed 或 emitCompleted。
停止请求保持流接收与执行位。abort.remote 的 accepted 仅表示受理,unknown 表示未确认,confirmed 表示事件或历史已确认终态;history 可补齐最终内容。
HistoryRunSnapshot.abort 记录本地停止请求的形状:requestedAt 为停止发起时间(非负有限数);acceptedAt 可空,一旦有值必须 >= requestedAt;outputSealed 为布尔,表示中止后输出是否封口(不再追加产出),不要求 run 一定是 interrupted、也不要求 acceptedAt 已有值——终态(completed/failed)可以先于远端停止 ACK 到达。Core 据此校验历史快照:即便 outputSealed=true 而 acceptedAt 未到、或终态与 interrupted 不符,也不整批拒收历史。
内容扩展
内容更新、默认翻译、事件序列与渲染器的完整约定见 适配器扩展 API。updateContent 只更新展示数据,不写执行状态。
独立订阅恢复时,resume 必须让同一会话通道从 eventId 之后完整、有序地重放,再接入实时输出。不能让重放与实时增量乱序混入同一轮;SDK 不比较不透明游标的大小。历史活动轮次在恢复前收到的尾帧不作为重放替代。