后端不符合约定时

这一页说明后端的格式或能力和后端约定不一样时怎么办。

结论

  • 适配器不提供兼容不规范后端的开关。
  • 差异有三种处理办法:在能力函数里转换;加自己的服务层;不给这项能力。
  • 数据都有、只是格式不同,用第一种。缺数据、要存东西才能补上,用第二种。补不了或不值得补,用第三种。
后端不符合约定时改在哪里浏览器自己的服务端现有的后端适配器不为个别后端改能力函数接入方写服务层接入方写后端格式或能力不符合约定做法一:在函数里转换字段名、结构不一样数据都有,只是格式不同做法二:加服务层缺编号、缺历史、缺会话要存东西才能补上做法三:不给这项能力补不了,也不值得补不启用对应远端能力
适配器不变。差异要么在能力函数里抹平,要么在服务层里补上,要么不给这项能力。

选哪一种

后端的情况做法
历史、会话的字段名或结构和约定的不一样在函数里转换
失败时返回 200 加一个错误字段在函数里转换
响应外面多包了一层,例如 { code, data }在函数里转换
流里没有事件编号,又要续接加服务层
后端编号无法与本地轮次关联,又要停止、历史加服务层
后端没有历史接口、会话接口加服务层
后端不能作废旧的一轮不给重新生成、编辑
后端不能按事件编号续接,短期内也不会做不给续接

做法一:在函数里转换

history、sessions 等单次能力返回 Response。格式不同时可读出、转换后再包装;流式能力也可返回异步事件序列,不能先把整条流当普通 JSON 读完。

下面的后端有历史接口,但字段名不一样:

adapter.ts
import {
  createOpenAiAdapter,
  type OpenAiHistory,
  type OpenAiHistoryMessage,
} from '@volcengine/chatkit/adapters/openai';

/** 后端现有的历史接口返回的格式:字段名和约定的不一样。 */
interface LegacyHistory {
  items: { msgId: string; turn: string; role: 'user' | 'assistant'; text: string; ts: number }[];
  next?: string;
}

const toMessage = (item: LegacyHistory['items'][number]): OpenAiHistoryMessage => ({
  id: item.msgId,
  run_id: item.turn,
  created_at: item.ts,
  role: item.role,
  content: item.text,
});

export const adapter = createOpenAiAdapter({
  run: ({ sessionId, runId, body }) =>
    fetch(`/api/chat/sessions/${sessionId}/runs`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ run_id: runId, ...body }),
    }),
  history: async ({ sessionId, cursor }) => {
    const query = cursor ? `?next=${encodeURIComponent(cursor)}` : '';
    const response = await fetch(`/api/legacy/${sessionId}/messages${query}`, {});
    // 失败的响应原样返回,适配器按状态码处理
    if (!response.ok) return response;
    const legacy = (await response.json()) as LegacyHistory;
    const history: OpenAiHistory = {
      messages: legacy.items.map(toMessage),
      ...(legacy.next ? { cursor: legacy.next, has_more: true } : {}),
    };
    // 转成约定的格式,再包成响应返回
    return new Response(JSON.stringify(history), { headers: { 'Content-Type': 'application/json' } });
  },
});
要点说明
失败的响应原样返回适配器按状态码处理失败,不用自己判断
转换后的内容用约定的类型标注例如 OpenAiHistory。格式写错时类型检查会报错
流也可以转换用 TransformStream 逐段改写,再用改写后的流建一个新的 Response
转换只改格式,不补数据后端没有的数据(编号、时间、每一轮的结果)转换不出来,要用第二种做法

后端用 200 表示失败、响应外面多包了一层时,同样在函数里处理:

adapter.ts
import { createOpenAiAdapter } from '@volcengine/chatkit/adapters/openai';

/** 后端不管成功失败都返回 200,结果包在一层里:code 不是 0 表示失败。 */
interface Wrapped {
  code: number;
  msg: string;
  data: unknown;
}

const unwrap = async (response: Response): Promise<Response> => {
  if (!response.ok) return response;
  const wrapped = (await response.json()) as Wrapped;
  // 失败:换成非成功状态,错误文字放在约定的位置
  if (wrapped.code !== 0) {
    const error = { code: String(wrapped.code), message: wrapped.msg };
    return new Response(JSON.stringify({ error }), { status: 502 });
  }
  // 成功:去掉外面包的那一层
  return new Response(JSON.stringify(wrapped.data));
};

export const adapter = createOpenAiAdapter({
  run: ({ body }) =>
    fetch('/api/chat/completions', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
    }),
});

做法二:加自己的服务层

后端缺数据时,在浏览器和后端之间加一层自己的服务。请求经过它,它来补后端没有的东西:

后端缺什么服务层怎么补
事件编号转发流的时候给每个事件加上 id:,同时把事件按轮次存下来
续接收到续接请求时,从存下来的事件里找到指定的编号,发它之后的
轮次编号收到发起请求时记下前端给的轮次编号,和后端这一次调用对应起来
停止按轮次定位执行并调用上游取消能力;仅断开连接不能保证上游停止
历史转发的同时把输入和输出存下来,按约定的格式提供历史接口

下面是其中一件事的例子,给上游的流加事件编号。这段代码运行在服务端:

with-event-ids.ts
/**
 * 服务层的代码(运行在自己的服务端,不在浏览器里):上游的流没有事件编号,转发时给每个事件加上 `id:`。
 * 加上之后还要把事件按轮次存下来,续接请求到来时从指定的编号之后接着发。
 */
export const withEventIds = (upstream: Response, onEvent: (eventId: string, frame: string) => void): Response => {
  if (!upstream.body) return upstream;
  let buffer = '';
  let sequence = 0;
  const encoder = new TextEncoder();
  const numbered = new TransformStream<string, Uint8Array>({
    transform(chunk, controller) {
      buffer += chunk;
      // SSE 的事件之间用空行隔开
      const frames = buffer.split('\n\n');
      buffer = frames.pop() ?? '';
      for (const frame of frames) {
        const eventId = `e${++sequence}`;
        onEvent(eventId, frame);
        controller.enqueue(encoder.encode(`id: ${eventId}\n${frame}\n\n`));
      }
    },
  });
  return new Response(upstream.body.pipeThrough(new TextDecoderStream()).pipeThrough(numbered), {
    status: upstream.status,
    headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache' },
  });
};

服务层同时也是生产环境要有的那一层代理:密钥留在这里,用户是谁由这里根据登录态决定。

做法三:不给这项能力

后端做不到、服务层也不值得补的能力,不给对应的函数。能力相关入口按配置隐藏或禁用;未提供 abort 时不能停止已派发的轮次。

adapter.ts
import { createHermesAdapter } from '@volcengine/chatkit/adapters/hermes';

const send = (method: string, path: string, body?: unknown) =>
  fetch(`/api/hermes${path}`, {
    method,
    headers: { 'Content-Type': 'application/json' },
    ...(body === undefined ? {} : { body: JSON.stringify(body) }),
  });
const session = (sessionId: string) => `/api/sessions/${encodeURIComponent(sessionId)}`;

// 后端不能按事件编号续接,也不能作废旧的一轮:resume、regenerate、edit 都不给。
// 界面上没有重新生成和编辑;连接中途断开时这一轮显示为失败,可以重试
export const adapter = createHermesAdapter({
  run: ({ sessionId, body }) => send('POST', `${session(sessionId)}/chat/stream`, body),
  history: ({ sessionId }) => send('GET', `${session(sessionId)}/messages`),
});
没给界面
resume连接中断显示结果未知,可查询结果
regenerate回答下方没有重新生成
edit用户消息上没有编辑
abort不显示停止按钮

以后后端支持了,加上对应的函数就行,别的代码不用动。

为什么不提供兼容的开关

原因说明
开关列不全每个不规范的后端都有自己的不规范。为一家加一个开关,开关会越来越多,组合起来的行为没法测全
开关会把问题藏起来适配器悄悄兼容之后,后端不知道自己不符合约定,换一个前端、换一个版本问题又出现
转换写在函数里更清楚差异在哪、怎么抹平,都在接入方自己的代码里,能看到、能测、能改
所有接入方的适配器行为一致同一个适配器在每个接入方那里行为相同。出了问题可以对着后端约定逐条排查

SDK 的数据约定不是所有后端的官方协议。可长期使用经过验证的转换或代理,无需为了字段名差异改动后端协议。

示例的适用范围

历史转换示例仅映射消息,没有补造 runs;它不能恢复未提供的执行结论。unwrap 示例只处理普通 JSON 接口,不能用于 SSE。

withEventIds 只演示以 LF 空行分帧后添加编号,没有覆盖 CRLF、已有 id、尾部残帧、异步持久化和跨连接编号连续性。它不是完整重放服务;编号、已应用历史前缀和事件持久化需要一起设计。

省略能力时,断线不会显示为失败:未收到协议结论时结果未知,也不会自动重发。