#上线前检查
这一页列出接后端这部分上线前要检查的事,以及出了问题从哪查。界面侧的整体上线清单(埋点、样式、插槽、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 取能力名。