上线前检查

这一页列出接后端这部分上线前要检查的事,以及出了问题从哪查。界面侧的整体上线清单(埋点、样式、插槽、SSR)见上线清单。

结论

  • 密钥不放前端,用户是谁由登录态决定,请求走自己的代理。
  • 给出的每一项能力都要验证过:用一致性测试验证适配器这一侧,用手工检查验证真实的后端。
  • 优先检查三处:地址或部署参数拼错;流里没有事件编号;历史里缺字段。

检查清单

安全

检查项要求怎么确认
密钥不放前端模型、Agent 的密钥留在服务端。前端代码和浏览器的请求里都没有检查前端环境变量、构建产物、网络请求和日志;一次搜索不能证明绝无泄露
用户是谁由登录态决定代理验证登录态及资源权限;客户端传来的用户 ID 不能作为授权依据改掉请求里任何和用户有关的字段,看到的仍是自己的数据
请求走自己的代理能力函数里的地址都是自己服务的地址网络面板里没有直接发往模型或 Agent 的请求
会话属于当前用户代理检查会话编号是不是这个用户的用别人的会话编号请求历史,得到的是拒绝
凭据过期能恢复凭据在函数里每次现取,过期后不用刷新页面让凭据过期后再发一条

凭据会过期时的写法:

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

/** 换成自己取令牌的办法:过期了就刷新,返回当前有效的那个。 */
declare const getAccessToken: () => Promise<string>;

export const adapter = createOpenAiAdapter({
  // 凭据会过期时在函数里每次现取,不用重建适配器
  run: async ({ body }) =>
    fetch('/api/chat/completions', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${await getAccessToken()}` },
      body: JSON.stringify(body),
    }),
});

代理

检查项要求没做到的表现
事件流不缓冲代理和它前面的网关都逐段转发回答等很久之后整段出现
状态码原样透传上游失败时代理返回同样的状态码失败的请求被当成成功,界面显示空回答
断开传到上游前端断开请求时,代理断开和上游的连接可能继续生成;断开不等于远端取消,需核对取消接口与结论
空闲不被掐断网关的空闲超时比一轮里最长的静默时间长,或者后端定时发心跳长时间的工具调用中途断线

能力

检查项要求
只给后端实现了的能力后端没有实现的,不给对应的函数
回调不需要接入 signal停止接口与接收流独立;释放时验证 reader / iterator 清理
适配器实例引用稳定建在模块级或用 useMemo 包住,同一后端配置稳定复用
错误接到监控适配器的 onError 和 useChatSessions 的 onError 都接到错误监控
轮次编号同一轮从头到尾一个编号。协议没有编号的,后端认前端给的
事件编号给了 resume 时,流里每个事件都带编号
刷新后的表现给了 history 时刷新后对话还原;没给时确认清空是可以接受的

用一致性测试验证

一致性测试随包发布,入口是 @volcengine/chatkit/testing 的 runAdapterConformance。 它让后端表现出四种行为,检查适配器交出的结果对不对。

做法:把发请求的函数做成可以替换的,应用里用浏览器的 fetch,测试里换成假后端。

适配器:

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

/**
 * 应用里用的适配器。发请求的函数从参数进来:应用里用浏览器的 fetch,测试里换成假后端。
 */
export const createChatAdapter = (request: typeof fetch = fetch) => {
  const send = (method: string, path: string, body?: unknown) =>
    request(`/api/chat${path}`, {
      method,
      headers: { 'Content-Type': 'application/json' },
      ...(body === undefined ? {} : { body: JSON.stringify(body) }),
    });

  return createOpenAiAdapter({
    run: ({ sessionId, runId, body }) => send('POST', `/sessions/${sessionId}/runs`, { run_id: runId, ...body }),
    regenerate: ({ sessionId, runId, targetRunId, body }) =>
      send('POST', `/sessions/${sessionId}/runs`, { run_id: runId, replace_from: targetRunId, ...body }),
    abort: ({ sessionId, runId }) => send('POST', `/sessions/${sessionId}/runs/${runId}/cancel`, {}),
    history: ({ sessionId }) => send('GET', `/sessions/${sessionId}/messages`),
  });
};

测试:

chat-adapter.conformance.test.ts
import { runAdapterConformance } from '@volcengine/chatkit/testing';
import { describe, expect, it } from 'vitest';

import { createChatAdapter } from './chat-adapter';
import { createFakeBackend } from './fake-backend';

// 每个用例都会要一个全新的适配器;kind 指定这一次后端要表现出的行为
runAdapterConformance(
  'chat',
  {
    createAdapter: kind => createChatAdapter(createFakeBackend(kind)),
    makeInput: text => ({ content: [{ type: 'text' as const, text }] }),
    capabilities: {
      // 没有注册浏览器里执行的工具,工具调用的用例不跑
      tools: false,
      // 给了 regenerate 和 sessions,把对应的用例打开
      regenerate: true,
    },
  },
  { describe, it, expect }
);

假后端按后端约定的格式应答。响应的内容要和真实后端一致,最好直接从真实后端录下来:

fake-backend.ts
import type { OpenAiHistory, OpenAiHistoryMessage, OpenAiHistoryRun } from '@volcengine/chatkit/adapters/openai';
interface BackendSession {
  id: string;
  title?: string;
  created_at?: number;
  updated_at?: number;
}
import type { ConformanceScenario } from '@volcengine/chatkit/testing';

/**
 * 假后端:按后端约定的格式应答,数据存在内存里。
 * 响应的内容要和真实后端一致,最好直接从真实后端录下来。
 */
const encoder = new TextEncoder();
const json = (value: unknown, status = 200): Response => new Response(JSON.stringify(value), { status });
const chunk = (delta: object, finish: string | null = null): string =>
  `data: ${JSON.stringify({ choices: [{ index: 0, delta, finish_reason: finish }] })}\n\n`;

/** 事件流,每个事件带事件编号。给了 signal 时流一直开着,直到请求被取消。 */
const stream = (frames: string[], signal?: AbortSignal): Response =>
  new Response(
    new ReadableStream<Uint8Array>({
      start(controller) {
        frames.forEach((frame, index) => controller.enqueue(encoder.encode(`id: e${index + 1}\n${frame}`)));
        if (!signal) {
          controller.close();
          return;
        }
        signal.addEventListener('abort', () => controller.error(new DOMException('aborted', 'AbortError')), {
          once: true,
        });
      },
    }),
    { status: 200, headers: { 'Content-Type': 'text/event-stream' } }
  );

export const createFakeBackend = (kind: ConformanceScenario): typeof fetch => {
  let sessions: BackendSession[] = [];
  let messages: OpenAiHistoryMessage[] = [];
  let runs: OpenAiHistoryRun[] = [];
  let sequence = 0;

  const startRun = (body: {
    run_id: string;
    replace_from?: string;
    messages: { role: string; content: unknown }[];
  }) => {
    if (body.replace_from) {
      // 被重做的那一轮和它之后的内容作废
      const from = messages.findIndex(message => message.run_id === body.replace_from);
      if (from >= 0) messages = messages.slice(0, from);
      const at = runs.findIndex(run => run.id === body.replace_from);
      if (at >= 0) runs = runs.slice(0, at);
    }
    const input = body.messages.at(-1)?.content;
    const next = () => `m${(sequence += 1)}`;
    messages.push({ id: next(), run_id: body.run_id, role: 'user', content: typeof input === 'string' ? input : '' });
    if (kind === 'fail') {
      runs.push({ id: body.run_id, status: 'failed', error: { code: 'rate_limit_exceeded', message: '限流了' } });
      return json({ error: { code: 'rate_limit_exceeded', type: 'requests', message: '限流了' } }, 429);
    }
    if (kind === 'slow') {
      messages.push({ id: next(), run_id: body.run_id, role: 'assistant', content: '处理中' });
      runs.push({ id: body.run_id, status: 'interrupted' });
      return undefined;
    }
    messages.push({ id: next(), run_id: body.run_id, role: 'assistant', content: '这是回答' });
    runs.push({ id: body.run_id, status: 'completed', last_event_id: 'e4' });
    return stream([chunk({ content: '这是' }), chunk({ content: '回答' }), chunk({}, 'stop'), 'data: [DONE]\n\n']);
  };

  return async (input, init) => {
    const path = String(input).replace('/api/chat', '');
    const method = init?.method ?? 'GET';
    const body = typeof init?.body === 'string' ? JSON.parse(init.body) : undefined;
    const sessionId = /^\/sessions\/([^/]+)/.exec(path)?.[1];

    if (method === 'POST' && /\/runs$/.test(path)) {
      return startRun(body) ?? stream([chunk({ content: '处理中' })], init?.signal ?? undefined);
    }
    if (method === 'POST' && /\/cancel$/.test(path)) return new Response(null, { status: 204 });
    if (method === 'GET' && /\/messages$/.test(path)) return json({ messages, runs } satisfies OpenAiHistory);
    if (method === 'GET' && path === '/sessions') return json({ sessions, has_more: false });
    if (method === 'POST' && path === '/sessions') {
      const session = { id: `s-${(sequence += 1)}`, title: body?.title, updated_at: Date.now() };
      sessions = [session, ...sessions];
      return json(session);
    }
    if (method === 'PATCH' && sessionId) {
      sessions = sessions.map(session => (session.id === sessionId ? { ...session, title: body.title } : session));
      return new Response(null, { status: 204 });
    }
    if (method === 'DELETE' && sessionId) {
      sessions = sessions.filter(session => session.id !== sessionId);
      return new Response(null, { status: 204 });
    }
    return json({ error: { code: 'not_found', message: `没有这个接口:${method} ${path}` } }, 404);
  };
};

测试检查什么

用例后端的行为检查什么对应的能力
文本回复正常回答用户输入先显示;回答累积;这一轮以完成结束run
工具调用调一次工具出现工具调用;这一轮完成时没有还在运行的工具run
后端失败返回失败这一轮以失败结束,带错误码;用户消息还在run
停止一直输出停止后这一轮以中断结束abort(没给也检查)
历史一致正常回答,再取历史实时显示的和历史读回来的,消息编号、片段编号、内容都一样history
重新生成正常回答,再重新生成被重做的那一轮消失,新的一轮完成regenerate
能力一致—界面认为有的能力,适配器上都有对应的实现全部
测试的选项默认什么时候改
capabilities.tools开假后端不演示工具调用时关掉
capabilities.abort开假后端不演示一直输出的流时关掉
capabilities.history开给了 history 而假后端不提供历史时关掉
capabilities.regenerate关给了 regenerate 时打开

该套件不替代续接、插话、编辑、独立订阅和恢复的专项测试;远端取消确认、异常重放及只提供 create 的会话也需单独覆盖。

对着真实的后端逐项检查

一致性测试用的是假后端。每一项给出的能力,还要对着真实的后端检查一遍:

能力怎么检查通过的表现
run发一条消息回答逐字出现,结束后状态是完成
run让后端返回一次失败出现失败卡片,文字是后端给的
abort回答进行中点停止,看后端的日志界面立刻停下,后端这一轮也停了
resume回答进行中断开网络几秒再恢复内容接着出现,没有重复,没有缺失
resume回答进行中刷新页面刷新后先看到已有的内容,然后接着出现
steer回答进行中再发一句显式调用 steer 或启用对应交互后,后端确认已受理;普通发送默认可能排队
regenerate点重新生成,完成后刷新页面旧的回答消失;刷新后只有新的回答
edit编辑前面的一条用户消息后重发,完成后刷新页面那一条之后的内容都被替换;刷新后和刷新前一样
history跑一轮带工具调用的对话,刷新页面刷新前后内容、顺序相同,没有重复
history让一轮失败,刷新页面刷新后失败卡片还在

常见错误与排查

发不出去

现象原因怎么查
发送后立刻失败,状态码 404地址拼错网络面板里看请求的完整地址,和后端的接口文档逐段对照
ADK:发送失败,提示会话不存在应用名或用户 ID 拼错;或者会话还没建对照发起对话和新建会话两个请求里的应用名、用户 ID,要完全一样
ADK:会话列表是空的,对话却能进行列出会话和发起对话用的应用名、用户 ID 不一样同上
状态码 401、403凭据没带上或者过期了看请求头。凭据在函数里每次现取
浏览器报跨域错误直连的后端没有允许这个来源走自己的代理,和页面同源
OpenAI 兼容接口:状态码 400没有补模型名;或者后端不认 stream_options看请求内容里有没有 model;在 run 里去掉 stream_options 再试
创建适配器时报错没给 run—

显示不对

现象原因怎么查
回答等很久之后整段出现代理或网关缓冲了事件流网络面板里看事件是逐个到达还是一起到达
A2A:回答整段出现,工具之间的说明文字丢失服务端是 ADK 执行器,没有传 profile: 'adk'看流里的正文是在 status-update 还是 artifact-update 里
一直停在进行中后端的流没有给结论就一直开着网络面板里看流的最后一个事件是不是结束的标志
回答是空的,状态是完成失败的请求返回了 200;或者流的格式不是这个协议的看响应的内容和状态码
每次渲染都重新连接,流被掐断适配器在渲染时新建把适配器放到模块级,或用 useMemo 包住

续接不可用

现象原因怎么查
断线后结果未知,没有续接流里没有事件编号;或者没给 resume网络面板里看事件流,每个事件有没有 id(ADK 看事件里的 id,Hermes 看 seq)
续接回来的内容和已有的重复后端没有按事件编号截断,从头发了一遍看续接请求的响应,第一个事件的编号是不是在请求给的编号之后
刷新后还在进行的那一轮没有接上历史里没有标明这一轮还在进行,或者没有最后的事件编号看历史的响应:runs 里有没有 status: running 和 last_event_id
停止、续接的请求到了后端,后端找不到这一轮后端没有存前端给的轮次编号;或者一轮里用了几个编号对照发起请求里的轮次编号和停止请求里的轮次编号

历史不对

现象原因怎么查
刷新后历史里的回答没有重新生成消息上没有所属的轮次编号,或者历史里没有每一轮的结果看历史的响应里消息有没有 run_id,顶层有没有 runs
刷新后无法还原失败结论历史里没有每一轮的结果,缺少状态时无法恢复执行结果看 runs 里这一轮的 status 和 error
刷新后一轮变成了几轮后端按每次模型调用一个编号来存看历史里同一轮的消息,run_id 是不是同一个
刷新后同一段回答出现两遍历史里的轮次编号和实时流里的不一样对照发起请求里的轮次编号和历史里的 run_id
刷新后消息不显示时间消息上没有时间看历史的响应里消息有没有 created_at
刷新后业务卡片不见了卡片的数据只在流里有,历史里没有存见后端约定 · 已知限制第 1 条
打开会话报错,列表或历史是空的响应的格式不是约定的结合网络响应和错误上下文;并非每类解析错误都有 cause.detail 或经适配器 onError 上报

会话不对

现象原因怎么查
界面上没有会话列表页面没有放 SessionList,或传入的数组为空会话列表由应用渲染,适配器不提供,见会话列表
会话没有标题传给 SessionList 的条目没有 title检查应用把目录接口响应映射成 SessionInfo 的代码
切换页面再回来,对话没了对话界面所在的组件被卸载,又没有历史见会话与历史从哪来

从哪里拿排查信息

来源能看到什么
浏览器的网络面板请求的地址、请求内容、状态码;事件流里每个事件的内容和编号
适配器的 onError请求/扩展错误的类别、extra 与可选 cause;取消和部分解析/协议错误不走同一路径
useChatSessions 的 onError取历史、续接、派发的失败,第二个参数带来源;不写时交给 <ChatkitProvider> 的 onError
失败卡片显示 RunError.message;后端原文可能含内部信息,代理需控制可显示错误内容
adapter.ts
import { createOpenAiAdapter } from '@volcengine/chatkit/adapters/openai';
import type { AdapterFailureCause } from '@volcengine/chatkit/core';

export const adapter = createOpenAiAdapter({
  run: ({ body }) =>
    fetch('/api/chat/completions', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(body),
    }),
  // 能力函数抛错或返回非成功状态时各上报一次
  onError: (content, error, extra) => {
    // 是哪个能力、后端给的状态码、原始的错误内容在 cause 里
    const cause = (error instanceof Error ? error.cause : undefined) as AdapterFailureCause | undefined;
    console.error('[chat]', content, cause?.capability, cause?.status, cause?.detail, extra);
  },
});

订阅与重放专项检查

  • 受理前快速输出、未由本页面发起的轮次,以及两个会话交错输出,均落到正确的轮次。
  • 停止请求不封存输出,订阅继续接收所有轮次;释放会话只清理对应监听。远端受理与最终停止分别检查。
  • 历史前缀与游标来自同一版本;resume 只补后缀,不重发原始问题,也不混入乱序实时增量。直接流不能假定统一去重。
  • 空闲订阅断开后由应用重新连接;已有轮次的三次恢复尝试耗尽后,保留未知结果,不把它改成失败或完成。
  • 核对示例中的开发凭据、路径、404 建会话假设和字段扩展;类型检查只验证 TypeScript 形状,不能验证线上协议。

onError 示例的“各上报一次”仅描述常见请求失败路径,不是跨适配器、跨层的次数保证。四个内置适配器的 content 是错误类别;应同时检查 extra.capability,不能只从 cause 取能力名。