chatkit 适配器协议 v1

本文是「任何后端怎么接进 chatkit」的规范。类型定义在 @volcengine/chatkit/core, 可执行的版本是随包发布的一致性套件 runAdapterConformance(@volcengine/chatkit/testing): 下文每条带 【套件】 标记的规则,都有一条对应的断言。一致性套件验证通用行为,不能替代真实后端的协议、并发与恢复联调。

1. 三个角色

后端 ──wire──▶ Adapter ──事实(ingress.emit*)──▶ Core ──快照──▶ UI
                  ▲                                  │
                  └──────── 命令(run / abort …)─────┘
  • Adapter:唯一认识 wire 协议的地方。把后端的帧翻译成事实,把 Core 的命令翻译成请求。
  • Core:协议无关、零依赖。准入事实、推进 run 状态机、发布冻结的快照。它不发请求、不猜语义。
  • UI:只读快照、只画。不认识任何协议,也不知道自己接的是哪个 Adapter。

2. Adapter 契约

公开接口及完整调用规则见 Adapter 与会话 API 契约。

创建配置与 runtime 契约

接入方把能力函数传给工厂,工厂生成 ChatRuntimeAdapter。相同业务动作同名,但两层参数不同:Core 提供统一 input,Adapter 编码成 body 后调用接入函数;接入函数返回原始响应,Adapter 解码并向 Core 提交事实。

创建配置生成的 adapter 方法区别
runrun接入函数接收协议 body;adapter 方法接收统一 input 与既有 messages
resumeresume接入函数接收后端 eventId;adapter 方法接收 Core 保存的 from 游标
steersteer接入函数接收编码后的 body;adapter 方法接收追加 input
abortabort发起远端取消;等待事件或历史确认终态
regenerate / editregenerate / edit新 runId 承接回答,targetRunId 标识被重做的轮次
historyhistory接入函数返回 Response;adapter 方法返回 HistoryResult;cursor 可选
subscribeconnectsubscribe 建立原始事件流;connect 确保会话接通并处理复用、路由与缓冲
无start(ingress)绑定 Core 事实入口,返回解绑函数;普通接入方不实现

不需要自行实现 start 或持有 ingress;只有直接实现底层 ChatRuntimeAdapter 才会涉及它们。底层 run/regenerate/edit 返回 Promise,resume 返回 Promise<void | boolean>,steer/abort 返回 Promise。这些返回值不替代后端终态事实。

能力函数完整约定

普通能力回调不接收 signal。SDK 在释放运行时时取消 Response 的 reader,或调用异步迭代器的 return();自定义事件流必须在 return() 中移除监听并结束 pending next(),不要关闭其他会话共享的连接。回调返回前的请求超时由应用自己的网络客户端管理。资源释放不请求后端停止。

能力函数参数返回值
run(必填)sessionId, runId, body无 subscribe:StreamResult;有 subscribe:受理 Response
subscribesessionIdStreamResult;resolve 时监听和缓冲必须已经就绪
resumesessionId, runId, eventId;协议需要时含 body无 subscribe:StreamResult;有 subscribe:受理 Response
steersessionId, runId, body受理 Response
abortsessionId, runId;协议需要时含 body受理 Response
regenerate / editsessionId, runId, targetRunId, body与 run 相同
historysessionId, cursor?协议历史的 Response
type StreamEvent<Event> = { data: Event; eventId?: string };
type StreamResult<Event> = Response | AsyncIterable<StreamEvent<Event>>;

未提供的能力不挂到 adapter,UI 按有效能力显示执行操作。historyPaging 表示允许历史翻页,与独立方法无关;唯一 history 方法负责第一页与后续页,是否还有内容由 hasMore/cursor 决定。

HTTP 失败和抛错表示调用未完成;受理成功不等于执行完成。流关闭不能推断 completed。OpenAI 工具循环可以在同一 runId 下多次调用 run。

3. 事实

start 拿到的 RuntimeIngress 共 12 个成员,按职责分三类(事实 emitter 7 个均返回 FactResult):

事实 emitter(7)

入口含义
emitMessage新增或整条替换一条消息(按 message.id)
emitDelta往某条消息的某个 part 追加正文(按 messageId + partId,没有就建)
emitPhaserun 的阶段:sampling / tool / awaiting-external / finalizing
emitWorkItem结构化工作项(后台任务、子 Agent)的状态
emitCompleted远端给出「完成」结论
emitFailed远端给出「失败」结论,带 RunError
emitInterrupted远端给出「中断」结论

run 级标注与校准(2)

入口含义
markReconciliationRequired实时链路可能漏了东西,下次需要用历史校准
annotateRun给一轮补事件属性(模型、Agent…),不是对话事实,见下

被动轮登记 / 内容扩展 / 连接观测(3)

入口含义
registerRun?由明确的远端开始事件登记被动轮次,不占用本地发送队列
updateContent聊天流之外补充/修改已有消息的业务内容(卡片、工具 title),不写执行状态
reportConnection上报连接观测(connected/disconnected/recovering);不代表终态、不消耗事件游标

每条事实都带 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)整条丢掉,不让界面去猜。规则:

规则含义
missing-message-id消息缺 id
duplicate-message-id同一批里消息 id 重复
missing-part-idpart 缺 id
duplicate-part-id同一条消息里 part id 重复
invalid-data-kinddata part 的 kind 不合法或不在 part 外层(见第 7 节)
invalid-tool-calltool-call 缺 toolCallId / toolName,或 status / error 形状不对
  • 实时消息、发送时的本地投影、宿主的历史改写:这一条不进快照,其余照常。

  • 打开会话的历史、翻页:整批拒收,history.refresh() / loadMore() 抛出 InvalidMessageError。放宽成跳过这一条, 格式问题就会被掩盖成「少了几条消息」。

  • 每次都经 ChatCoreRuntime 的 onInvalidMessage 上报 InvalidMessageError(带 rule、messageId、partId、source, 不带消息内容),缺省 console.error;useChatRuntime 同时交给 onError。这是适配器的 bug,界面上不提示。

  • 【套件】 文本、工具、失败三种场景里,除幂等之外没有事实被拒收(invalid-message 不算幂等)。

4. 消息与 part

interface RuntimeMessage {
  id: string;
  role: 'system' | 'user' | 'assistant' | 'tool';
  parts: readonly RuntimePart[];
  runId?: string;
  createdAt?: number; // 这条消息产生的时间(毫秒时间戳),后端给了就带上,界面用它显示时间
  metadata?: Record<string, unknown>; // Adapter 的不透明元数据,Core 只存不解释
}
type RuntimePart = TextPart | ReasoningPart | ToolCallPart | MediaPart | DataPart;

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

interface RunError {
  code: string; // 机器可读、稳定:RESOURCE_EXHAUSTED、-32603、MODEL_OVERLOADED …
  message: string; // 给人看的一句话,界面直接渲染
  retryable?: boolean; // 原样重试可能成功吗?限流 / 过载 / 超时为 true,鉴权 / 参数错误为 false;不知道就不给
  cause?: unknown; // 协议原始错误载荷,只做排障透传
}
  • 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. 工具调用

interface ToolCallPart {
  type: 'tool-call';
  toolCallId: string; // 协议侧调用标识,结果依此回填
  toolName: string;
  args?: Record<string, unknown>;
  status?: 'running' | 'completed' | 'failed' | 'cancelled';
  result?: unknown; // undefined = 还没有结果;工具真实返回空时是 null
  partialResult?: unknown; // 运行中的阶段性输出,整份替换
  error?: RunError; // status 为 failed 时的原因
}
  • 生命周期:先 emitMessage 一个没有 result 的 part(运行中),拿到结果后整条替换成带 result 的版本。
  • status 可以不给:getToolCallStatus 会按有无 result 推断(没有是 running,有是 completed)。 只有协议给出了更多信息(失败、取消)时才需要显式写。
  • result 必须区分「没有结果」(undefined)和「结果为空」(null),否则要么永远转圈,要么空结果永远不出运行态。
  • 【套件】 工具场景里出现带 toolCallId 的 tool-call part;run 以 completed 收口时,没有仍在运行的工具。

7. 结构化数据:DataPart

interface DataPart {
  type: 'data';
  kind: string; // 命名空间化的类型名:厂商.名称
  data: unknown;
}
  • 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 第五节。

interface HistoryResult {
  messages: readonly RuntimeMessage[];
  runSnapshots?: readonly HistoryRunSnapshot[]; // 已经结束的轮次及其结果
  activeRun?: { runId: string; eventId?: string } | null; // 仍在进行的那一轮,和它最后一个事件的编号
  session?: SessionInfo; // 当前会话摘要,未提供状态为 unknown
  cursor?: string;
  hasMore: boolean;
}
  • 失败原因、中断原因、耗时都在 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) 负责分发:

import { ChatCoreRuntime, createSessionAdapters } from '@volcengine/chatkit/core';

const sessions = createSessionAdapters(adapter);
const runtime = new ChatCoreRuntime({ sessionId, adapter: sessions.forSession(sessionId) });
  • 适配器的 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 必须执行同一覆盖范围,旧版本只能独立于当前主线保存。

regenerate?(request: { sessionId; runId; targetRunId; input; messages }, context): Promise<boolean>;
edit?(request: { sessionId; runId; targetRunId; input; messages }, context): Promise<boolean>;
  • 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. 接入自检

import { runAdapterConformance } from '@volcengine/chatkit/testing';
import { describe, expect, it } from 'vitest';

runAdapterConformance(
  'my-backend',
  {
    // 按场景造一个 Adapter:text 正常回答、tool 调一次工具、fail 远端失败、slow 一直输出直到被中止
    createAdapter: kind => new MyAdapter({ run: scriptedRun(kind) }),
    makeInput: text => ({ text }),
    // 重新生成、会话接口的用例默认不跑,后端演示得了再打开
    capabilities: { regenerate: true },
  },
  { describe, it, expect }
);

仓库里的各个适配器都接了这套断言,可以直接照抄它们的 *.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 不比较不透明游标的大小。历史活动轮次在恢复前收到的尾帧不作为重放替代。