Skip to content
Chat Runtime 设计:消息状态、上下文维护与流式交互
1 Chat Runtime 的定义与运行边界
Chat Runtime 指为多轮对话提供执行环境的一组运行时组件。它接收用户消息,从会话存储中读取历史记录,组合出模型请求所需的上下文,调用语言模型获得增量输出,再把输出流式返回给客户端,最后把这一轮产生的消息写回存储。
Chat Runtime 与普通 Web 后端的一个区别在于状态的重要性。普通 Web 后端通常可以按无状态请求处理:请求进入、执行业务逻辑、返回响应,请求之间互不依赖。Chat Runtime 则不同。模型要正确响应当前消息,必然依赖此前的对话历史;即使客户端只发送一个“继续”,服务端也需要从历史上文推断语义。因此会话状态是 Chat Runtime 的数据核心,而不是可选配置。
Chat Runtime 与 Agent Runtime 也有区别。Agent Runtime 强调任务驱动的自主循环:模型规划下一步行动,调用工具,接收工具结果,继续推理,直到任务收敛。整个执行过程由目标驱动,工具调用是循环中的关键节点。Chat Runtime 虽然也支持工具调用,但工具调用是消息序列中的一个环节,行为的重心仍然是“维护一段连续的对话”,而不是“推进一个独立任务”。两者边界并不绝对,LangGraph 这类框架也可用于对话场景,但本篇讨论 Chat Runtime 时,把核心对象限定为会话中的消息,而非任务运行体。
2 基本概念:会话与消息的数据模型
2.1 会话(Session/Thread)
会话是消息的容器。一个会话对应一段持续对话,常见字段如下:
ts
interface Session {
id: string;
title?: string;
createdAt: number;
updatedAt: number;
metadata: Record<string, string>;
}metadata 用于保存附加信息,例如用户 ID、租户标识、语言偏好。服务端存储层可以按这些字段做租户隔离或查询过滤。
2.2 消息(Message)
消息是对话的基本单元。字段设计需要考虑状态记录、模型输入兼容性和错误追溯:
ts
type Role = 'system' | 'user' | 'assistant' | 'tool';
interface Message {
id: string;
sessionId: string;
role: Role;
content: string;
status: MessageStatus;
createdAt: number;
updatedAt: number;
tokenCount?: number;
error?: string;
metadata?: Record<string, unknown>;
}role对应模型输入中的角色。status标记消息生成过程中的生命周期状态,MessageStatus的定义见下一节。tokenCount是运行时记录的可选字段,在模型调用后回填,供上下文管理模块计算预算。error仅在失败时写入。metadata可保存客户端自定义标签、内部标记等信息。
2.3 业务状态与模型上下文状态
运行时中存在两类状态,需要区分。
- 业务状态:数据库或 Redis 中保存的会话与消息记录,描述“实际发生的对话”。
- 模型上下文状态:每次请求实际发送给模型的 token 序列,描述“模型看到的对话”。
业务状态是完整且持久的;模型上下文状态是某个时刻导出的快照,可能经过截断、摘要或重排。两者保持同步的含义是:上下文中的每一条内容都能在业务状态中追溯到来源记录;被模型消费的记录,也能在日志中识别出来。上下文组装模块是这两个状态之间的转换器。
3 消息状态机
助手消息从创建到定稿,通常会经过几个状态。这些状态不是协议强制的,不同实现可以使用不同名称,但通常都覆盖以下阶段:
pending:消息已写入存储,等待模型调用开始。streaming:模型正在产生增量输出,消息内容持续增长。completed:生成完成,消息定稿。failed:模型调用失败,保留错误信息。interrupted:生成被用户取消或系统中断,输出内容不完整。
状态之间的转移可以定义为状态机:
ts
type MessageStatus = 'pending' | 'streaming' | 'completed' | 'failed' | 'interrupted';
class MessageStatusMachine {
private state: MessageStatus = 'pending';
private allowed: Record<MessageStatus, MessageStatus[]> = {
pending: ['streaming', 'failed', 'interrupted'],
streaming: ['completed', 'failed', 'interrupted'],
completed: [],
failed: [],
interrupted: ['streaming'],
};
transition(next: MessageStatus) {
if (!this.allowed[this.state].includes(next)) {
throw new Error(`invalid transition: ${this.state} -> ${next}`);
}
this.state = next;
}
get value() {
return this.state;
}
}interrupted -> streaming 是可选能力:服务端如果保存了中断前的输出缓冲,允许客户端重连后从断点恢复增量输出。若不支持恢复,则该转换不应出现在 allowed 表中。
用户消息的状态转移更简单:pending 表示服务端已收到,进入模型调用后标记为 completed。如果模型调用失败,失败状态出现在对应助手消息上,而不是用户消息上。
4 工作原理:上下文窗口的组装
上下文窗口(context window)是模型单次请求能处理的最大 token 量。窗口大小因模型而异,例如 GPT-4o 为 128K tokens,Claude Sonnet 4 为 1M tokens [1]。组装上下文的任务是从会话历史中选取合适的内容,并按模型要求的顺序排列。
典型组装顺序:
system prompt
工具定义(若支持 function calling)
历史对话消息(从旧到新)
当前用户消息工具结果以 tool 角色的消息出现在历史中。组装时,tool 消息必须紧随带有对应 tool_calls 的 assistant 消息;如果缺少配对关系,模型将无法判断工具结果属于哪一次调用。
4.1 组装函数示例
下面的函数是示意实现,assembleContext 这个名称用于演示,不涉及标准 API。辅助函数 toContextMessage 负责把 Message 或 ToolDefinition 转换为 ContextMessage,具体实现省略:
ts
interface ContextMessage {
role: 'system' | 'user' | 'assistant' | 'tool';
content: string;
toolCallId?: string;
}
async function assembleContext(
session: Session,
history: Message[],
systemPrompt: string,
toolDefs: ToolDefinition[],
maxTokens: number,
): Promise<ContextMessage[]> {
const base: ContextMessage[] = [
{ role: 'system', content: systemPrompt },
...toolDefs.map(toContextMessage),
];
const budget = maxTokens - estimateTokens(base);
let used = 0;
const selected: ContextMessage[] = [];
// 从最新消息开始向前选取
for (let i = history.length - 1; i >= 0; i--) {
const msg = history[i];
const size = estimateTokens(msg.content) + 4; // role、时间戳等开销
if (used + size > budget) break;
selected.unshift(toContextMessage(msg));
used += size;
}
return [...base, ...selected];
}maxTokens 不应等于完整窗口大小。需要预留一部分空间给模型生成输出,预留多少取决于系统提示词与工具定义的复杂度,无法给出通用数值。组装前先估算基础消息的 token 数并记录日志,是更稳妥的做法。
4.2 Token 估算
精确 token 数依赖模型分词器。运行时如果不引入分词器,可以使用近似估算。不同语言和模型差异很大,因此应将估算逻辑集中,便于后续替换:
ts
function estimateTokens(text: string | ContextMessage[]): number {
if (Array.isArray(text)) {
return text.reduce((sum, m) => sum + estimateTokens(m.content) + 4, 0);
}
// 简单近似:中文约 1 字 1 token,英文约 4 字符 1 token
return Math.ceil(text.length / 1.5);
}4.3 上下文污染
上下文污染指模型实际看到的上下文与业务状态不一致,或包含过期、重复、无法配对的内容。
常见来源:
- 组装时把同一条消息重复加入,例如历史列表与当前消息重叠。
- 失败重试时,上一次未完成的工具结果还留在历史中。
- 多个 system prompt 片段被重复注入。
- 截断逻辑把一条消息中间截断,留下残缺内容。
- 重排或摘要后,旧消息仍被错误保留。
将上下文组装实现为纯函数,每次从业务状态重新推导上下文,可以显著减少污染。组装函数不直接修改业务状态,只在内存中生成模型输入;模型调用完成后,新产生的助手消息写入业务状态,下一次组装时再重新读取全部历史。
5 上下文维护:截断、摘要、压缩与长期记忆
当历史消息超出上下文窗口时,需要按策略减少 token 占用。
5.1 截断
截断指从最旧消息开始删除,直到剩余内容适配窗口。实现最简单,但会丢失早期关键信息,例如用户之前声明过的偏好或背景。
截断的优先级应当固定:先丢弃历史对话,再考虑降低长期记忆注入量,最后才考虑裁剪 system prompt 或工具定义。
5.2 摘要
摘要指对较早的对话调用一次额外模型调用,生成一段概括文本,替换原来的多条消息。摘要消息通常以 system 角色放在上下文开头:
system: 以下是用户早期对话的摘要:该用户咨询订单退款流程,已提供订单号 12345。摘要会增加一次模型调用和额外的 token 消耗,但语义保留效果好。预算允许时优先于截断。如果摘要后仍超出窗口,再退化到截断 [5]。
5.3 压缩
压缩是摘要的轻量变体:对单条超长消息做文本缩减,去除重复表达和冗余修饰,保留原有结构和关键事实。压缩不生成新的摘要消息,而是直接替换原消息的 content。
需要注意,压缩会改变原始消息内容。如果后续需要精确回放对话,应保留压缩前的原文,或把摘要作为独立消息追加,而不是覆盖原消息。
5.4 长期记忆
长期记忆解决跨会话信息保留。短期记忆存放当前会话最近的几轮消息,可以放在 Redis 这类快速存储中;长期记忆保存用户画像、偏好等低频变化的信息,可以使用 NoSQL 或向量数据库存储,需要时按语义检索注入 [5]。
ts
async function loadMemory(userId: string, query: string): Promise<string[]> {
const docs = await vectorDB.search({
collection: 'user_memory',
filter: { userId },
query,
limit: 5,
});
return docs.map((d) => d.content);
}长期记忆的写入时机通常选在会话结束或定期总结之后。写入内容应避免与短期记忆重复,否则再次注入时会造成上下文冗余。
5.5 长上下文的失效问题
窗口规格大不等于实际效果好。有研究指出,当上下文长度增加到一定程度后,模型对中间部分内容的利用会下降,出现 lost-in-the-middle 现象。因此,不应把窗口规格作为唯一依据,需要在目标上下文长度上做效果验证 [1]。
6 工具调用(function calling)的消息序列
工具调用需要三个角色配合:
assistant消息携带tool_calls字段。tool消息通过tool_call_id关联对应调用。- 后续
assistant消息基于工具结果继续作答。
一段完整序列如下:
ts
const messages = [
{ role: 'user', content: '北京明天天气如何?' },
{
role: 'assistant',
content: '',
toolCalls: [
{ id: 'call_1', name: 'get_weather', arguments: '{"city":"北京"}' },
],
},
{ role: 'tool', toolCallId: 'call_1', content: '多云,25°C' },
{ role: 'assistant', content: '北京明天多云,气温 25°C。' },
];运行时数据模型相应扩展:
ts
interface ToolCall {
id: string;
name: string;
arguments: string; // JSON 字符串
}
interface AssistantMessagePayload {
content: string;
toolCalls?: ToolCall[];
}
interface ToolMessagePayload {
toolCallId: string;
content: string;
}LangGraph 等运行时把模型与工具实现为图节点,通过检查点机制在每次图执行之间保存状态 [4]。这给 Chat Runtime 的启发是:工具调用不是一次请求/响应就结束的,而是模型与工具之间通过共享消息状态反复交互。工具执行结果必须持久化到会话消息列表,后续轮次才能引用。
工具调用的消息序列还要注意两点:
- 消息必须按顺序写回存储。先写
assistant的toolCalls,再写tool结果,避免上下文出现悬空引用。 - 重试模型调用时,上一次工具调用可能已经执行,重试可能造成重复副作用。工具服务端需要具备幂等控制能力。
7 API 与协议:流式响应与 SSE
SSE(Server-Sent Events)是服务端通过单个 HTTP 连接向客户端持续推送文本的协议。HTTP/1.1 下常用 chunked transfer encoding,服务端无需预知总长度即可开始发送;HTTP/2 使用原生 frame 传输同一类数据,对应用层代码无感知 [2]。
一个最小 SSE 端点:
ts
app.get('/stream', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Connection', 'keep-alive');
const timer = setInterval(() => {
res.write('data: "ping"\n\n');
}, 1000);
req.on('close', () => clearInterval(timer));
});SSE 事件格式可以包含 event 字段来区分事件类型:
id: evt_123
event: message_delta
data: {"id":"msg_456","delta":"北京"}
event: message_completed
data: {"id":"msg_456","content":"北京明天多云"}Chat Runtime 的常见事件类型:
message.started:助手消息已创建,状态pending。message.delta:增量内容,状态streaming。message.completed:输出完成,状态completed。message.failed:生成失败,状态failed。
注意点:SSE chunk 边界不保证等于 token 边界。OpenAI 的 /chat/completions 在 stream=true 时,每个 data 块通常包含完整的 token;但一些兼容实现可能把 token 拆成单个字符,每个 data 块只包含一个字符 [3]。客户端应把收到的文本片段按增量内容处理,不能按 chunk 边界来确定 token。
8 应用:前后端交互架构
8.1 接口划分
REST 同步端点用于会话管理和历史拉取:
| 方法 | 路径 | 用途 |
|---|---|---|
| POST | /sessions | 创建会话 |
| GET | /sessions/:id/messages | 获取会话历史,用于恢复和切换 |
| POST | /sessions/:id/messages | 发送消息,响应为 SSE 流 |
SSE 端点用于接收模型增量输出。WebSocket 可选,用于双向控制信号,例如发送取消指令、调整生成参数。
模型增量走 SSE、控制信号走 WebSocket 的组合方式比较常见。SSE 是单向服务端推送,服务器成本低、断线重连有标准支持;WebSocket 是双向通道,适合客户端主动发控制消息。
8.2 一次发送消息的完整数据流
- 客户端 POST 发送用户消息。
- 服务端存储用户消息,状态
completed。 - 服务端创建助手消息,状态
pending。 - 服务端组装上下文,调用模型。
- 助手消息状态改为
streaming。 - 模型增量输出经 SSE 推送到客户端。
- 输出结束,状态改为
completed。
8.3 无状态服务与共享存储
多实例部署时,SSE 连接可能落在任何一个后端实例上。客户端断开后重连,如果新实例没有原连接的数据,流就无法继续。解决办法是把流式中间输出放在共享存储中,而不是只存在单实例内存里 [2]。这样任意实例都能为重连客户端提供续传数据。
9 示例:最小实现
下面是一个最小可运行的服务端示例。它不依赖真实模型,用一个模拟流式生成器替代模型适配器,便于本地运行。类型定义参见第 2 节。
ts
import express from 'express';
import { randomUUID } from 'node:crypto';
const app = express();
app.use(express.json());
const messagesBySession = new Map<string, Message[]>();
function getMessages(sessionId: string): Message[] {
if (!messagesBySession.has(sessionId)) {
messagesBySession.set(sessionId, []);
}
return messagesBySession.get(sessionId)!;
}
// 模拟流式模型输出
async function* fakeModelStream(prompt: string) {
const answer = `收到:${prompt}`;
for (const ch of answer) {
await new Promise((r) => setTimeout(r, 20));
yield ch;
}
}
app.post('/sessions/:id/messages', async (req, res) => {
const { id } = req.params;
const userContent = req.body.content;
const list = getMessages(id);
list.push({
id: randomUUID(),
role: 'user',
content: userContent,
status: 'completed',
});
const assistantMsg: Message = {
id: randomUUID(),
role: 'assistant',
content: '',
status: 'streaming',
};
list.push(assistantMsg);
res.setHeader('Content-Type', 'text/event-stream');
res.setHeader('Cache-Control', 'no-cache');
res.write(
`event: message.started\ndata: ${JSON.stringify(assistantMsg)}\n\n`,
);
// 真实场景中,这里应调用上下文组装模块
const prompt = userContent;
try {
for await (const chunk of fakeModelStream(prompt)) {
assistantMsg.content += chunk;
res.write(
`event: message.delta\ndata: ${JSON.stringify({
id: assistantMsg.id,
delta: chunk,
})}\n\n`,
);
}
assistantMsg.status = 'completed';
res.write(
`event: message.completed\ndata: ${JSON.stringify(assistantMsg)}\n\n`,
);
} catch (err) {
assistantMsg.status = 'failed';
res.write(
`event: message.failed\ndata: ${JSON.stringify({
id: assistantMsg.id,
error: (err as Error).message,
})}\n\n`,
);
} finally {
res.end();
}
});这个示例没有包含上下文组装、token 计算、持久化和取消逻辑。它演示的是最核心的流程:消息入库、状态设为 streaming、增量推送、状态收尾。
10 注意点:取消、超时、重试与幂等性
10.1 取消
客户端取消生成有两种方式:
- 直接关闭 SSE 连接,服务端通过
req.on('close')感知并终止生成任务。 - 通过 WebSocket 发送取消消息,携带
messageId。
Node.js 中使用 AbortController 终止模型调用:
ts
const controller = new AbortController();
modelCall({ signal: controller.signal });
app.ws('/cancel', (ws, req) => {
ws.on('message', (msg) => {
const { messageId } = JSON.parse(msg.toString());
controller.abort();
});
});取消后,助手消息状态应更新为 interrupted,并把已生成的部分内容保留在消息记录中。
10.2 超时
超时配置需要区分两类:
- 模型调用超时:从发起调用到返回首字节,通常设置数十秒。
- 流式连接超时:长时间没有任何增量事件,则判定连接异常。
超时后通过 AbortController 终止调用,并将消息状态标记为 failed,错误信息中注明 timeout。
ts
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 30_000);
modelCall({ signal: controller.signal }).finally(() => clearTimeout(timer));10.3 重试与幂等
客户端因网络问题未收到响应时,重发同一请求可能导致消息重复。解决方式是为每次发送生成请求 ID,服务端在处理前检查是否已处理过该 ID:
ts
const processed = new Set<string>();
app.post('/messages', (req, res) => {
const key = req.headers['idempotency-key'] as string;
if (processed.has(key)) {
return res.status(200).json({ cached: true });
}
processed.add(key);
// 正常处理
});Idempotency-Key 是自定义请求头,服务端在跨域场景下需要显式允许携带。工具调用的重试还要考虑外部副作用:如果上一次工具调用已经执行,重试模型请求不应再次执行同一工具;这需要在工具服务端做幂等控制。
11 限制与架构:服务端状态存储与多实例一致性
短期工作记忆(当前会话最近几轮)适合放在 Redis 这类快速存储中,长期记忆则落在数据库或向量库 [5]。状态存储可以分为两级:
- 热数据:最近消息、流式中间输出,带过期时间。
- 持久数据:完整会话历史,长期保存。
流式中间输出写 Redis 的示例:
ts
import { createClient } from 'redis';
const client = createClient();
export async function saveStreamBuffer(messageId: string, content: string) {
await client.set(`stream:${messageId}`, content, { EX: 600 });
}客户端重连后,新的后端实例可以从 Redis 读取未完成的流式内容,继续向前端发送。
多实例并发写入同一会话时,需要避免覆盖。常见约定是:消息追加到列表尾部,各消息的 ID 全局唯一;读取时按创建时间或自增序号排序。更新操作尽量限定在单条消息内,不重写整个消息数组。
12 客户端状态同步契约
客户端需要镜像服务端的消息状态。同步契约至少包含消息 ID、状态、增量内容或最终内容、服务端时间戳。
事件驱动的状态同步:
ts
function onMessageDelta(delta: { id: string; delta: string }) {
const msg = localMessages.find((m) => m.id === delta.id);
if (msg) msg.content += delta.delta;
}断线重连后,客户端与服务端可能处于不一致状态。同步流程:
- 客户端记录本地最新消息 ID。
- 重连后请求
GET /sessions/:id/messages?after=<messageId>。 - 服务端返回该 ID 之后的所有消息。
- 客户端用返回结果覆盖本地状态。
使用消息 ID 或事件序号作为同步游标,比时间戳更可靠。分布式环境下,不同服务实例的时钟可能存在偏差。
13 可观测性:日志、链路追踪与 Token 计量
13.1 日志
每个请求至少记录:
- 会话 ID、消息 ID
- 角色、状态
- token 估算值与实际值
- 模型名称、调用延迟
13.2 链路追踪
一条用户消息从进入 API 到最终返回,会经过网关、运行时、模型适配器、工具服务。使用统一 traceId 贯穿这些环节,可以把日志串联起来。
ts
app.use((req, res, next) => {
req.headers['x-trace-id'] ??= randomUUID();
next();
});13.3 Token 计量
模型调用后需要回填三类数值:
- prompt tokens:输入侧的 token 数
- completion tokens:输出侧的 token 数
- total tokens:两者之和
这些数据用于成本核算和上下文策略调优。上下文组装器也应记录每次裁剪前的估算 token 数,便于确认截断或摘要策略是否生效。
14 小结
Chat Runtime 的核心对象是会话与消息。消息状态机标识生成过程,上下文组装器决定模型看到的内容,SSE 通道负责把增量输出返回前端,工具调用扩展了消息序列的表达能力。取消、超时、重试的定义,则明确了消息状态的可逆边界。
后续主题可以继续讨论:接入 RAG 系统时上下文如何注入、多模态消息的数据模型如何扩展,以及模型评测在运行时中的位置。这些方向都建立在会话与消息这两个基础对象之上。
