多轮对话接口
要点
- LLM 本身没有记忆——每次请求都要把历史消息以
messages数组的形式完整传过去,服务端的工作就是把这份历史拼好、截好、发出去 - 会话 ID 是连接「用户连续发的多条消息」的纽带,用 UUID v4 足够,不需要自增 ID
- 对话历史存在 KV 里,按
conversation:{sessionId}做 key,每次请求读取、追加、写回 - 上下文窗口有上限,Token 算超了就要截断——常见策略是保留 system prompt 和最近 N 条消息,把更早的消息丢掉
- Token 计算可以用字符数做粗略估算,也可以用
tiktoken等库做精确计算 - 整个多轮对话接口可以拆成三层:路由层(接请求)、会话管理层(读写历史)、消息组装层(截断 + Token 控制)
1. 从单轮问答到多轮对话
上一篇的单轮问答接口,请求结构是这样的:
// POST /api/chat
{
prompt: "什么是 TypeScript?"
}每次请求只带一句 prompt,服务端拼一个 messages: [{ role: 'user', content: prompt }] 发给 LLM,拿回回答就结束。没有历史,没有上下文。
这种模式能跑通,但用户体验有明显的问题:
- 用户问「它有什么优缺点?」——「它」指的是什么?上一次问的 TypeScript。但服务端不知道
- 用户说「换一种更简单的解释」——更简单是相对于谁的回答?上一次的。但上一次的内容服务端已经丢了
- 用户想围绕同一个话题连续讨论,每次都要把前因后果重新描述一遍
把视线收回到 LLM 的调用方式,会发现一个关键事实:LLM 没有记忆。它不是「聊着聊着就记住了」,而是每次请求都要把所有信息完整传过去。所谓「多轮对话」,本质上是服务端帮 LLM 把历史消息拼起来。
2. messages 数组:LLM 的输入格式
LLM 的输入不是纯文本字符串,而是一个结构化的 messages 数组。每个元素有三个字段:
role:这条消息是谁说的。常见取值:system、user、assistantcontent:消息的文本内容- (部分模型支持更多 role,如
tool,这里先不展开)
一个典型的多轮对话输入长这样:
// messages.ts
const messages = [
{ role: 'system', content: '你是一个前端开发导师,用简洁的语言回答问题。' },
{ role: 'user', content: '什么是 TypeScript?' },
{ role: 'assistant', content: 'TypeScript 是 JavaScript 的超集,主要特点是加了静态类型系统。' },
{ role: 'user', content: '它有什么优缺点?' },
{ role: 'assistant', content: '优点:编译期发现错误、IDE 补全更好、重构更安全。缺点:学习成本、编译步骤、小项目收益不明显。' },
{ role: 'user', content: '能展开说说编译期发现错误吗?' },
]LLM 看到这个数组,就知道前面已经讨论过 TypeScript 的基本概念,现在要展开讲类型检查的好处。
所以服务端要做的,就是在每次收到用户消息时,把之前的历史从存储里读出来,拼成这个数组,再发给 LLM。
3. 会话 ID:把连续的消息串起来
一个用户可能同时开好几个聊天窗口,每个窗口讨论不同的话题。服务端怎么区分这些对话?
答案是给每段对话分配一个 sessionId,后续这个窗口里所有的消息都挂在同一个 session 下。
会话 ID 怎么生成
最常见的做法是用 UUID v4——随机生成、全球唯一、不需要中心分配:
// session-id.ts
import { randomUUID } from 'crypto'
// 用户点击「新建对话」时,前端调这个接口
app.post('/api/conversations', (c) => {
const sessionId = randomUUID()
// sessionId 返回给前端,后续所有请求都带上它
return c.json({ sessionId })
})UUID v4 的碰撞概率极低(2^122 种可能),不需要担心重复。
为什么不用自增 ID?自增 ID 会暴露业务量(id=42 说明你只有 42 个会话),分布式环境下还需要额外的协调机制。UUID 在 Worker 里直接 randomUUID() 就出来了,零依赖。
前端怎么使用
前端拿到 sessionId 之后,后续每次发消息都带上它就行:
// client.ts
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
sessionId: 'a1b2c3d4-...',
message: '能展开说说编译期发现错误吗?',
}),
})4. 用 KV 存储对话历史
对话历史需要一个地方存。Cloudflare KV 是一个简单的键值存储,读取速度快、按量计费,非常适合存对话历史这种「读多写多、单条不大」的数据。每个会话对应一个 key(conversation:{sessionId}),value 是这个会话的完整消息数组。
读写操作
// conversation-store.ts
import type { Message } from './types'
const KEY_PREFIX = 'conversation:'
// 读取历史消息
async function getHistory(
kv: KVNamespace,
sessionId: string
): Promise<Message[]> {
const key = `${KEY_PREFIX}${sessionId}`
const raw = await kv.get(key, 'json')
return (raw as Message[]) ?? []
}
// 追加新消息(读 → 追加 → 写回)
async function appendMessage(
kv: KVNamespace,
sessionId: string,
message: Message
): Promise<void> {
const history = await getHistory(kv, sessionId)
history.push(message)
const key = `${KEY_PREFIX}${sessionId}`
// KV 的 value 最大 25 MB,对话历史远不会到
await kv.put(key, JSON.stringify(history))
}给 key 加过期时间
对话不可能永远留着。给每个 key 设一个 TTL(Time To Live),过期后 KV 自动删除,不用自己写清理逻辑。在 kv.put 的第三个参数加上就行:
// 7 天没新消息就自动过期
await kv.put(key, JSON.stringify(history), { expirationTtl: 60 * 60 * 24 * 7 })每次追加消息时都重新设 TTL,效果是「最后一条消息之后 7 天过期」。用户持续对话的话,这个倒计时会一直往后推。
5. 上下文窗口与 Token 控制
把历史全部传给 LLM,听上去简单,但有一个硬限制:模型的上下文窗口有大小。
以 Claude 为例,输入上限大约是 200K tokens。GPT-4o 是 128K。LLaMA 3 是 8K 或 128K,取决于具体版本。tokens 不是字符数——英文里 1 个 token 大约是 4 个字符或 0.75 个单词,中文里 1 个汉字通常是 1-2 个 token。
如果对话历史超过了窗口上限,请求会直接报错。就算没超限,输入越长,推理越慢、费用越高。
Token 计算
精确计算 Token 数需要用模型对应的 tokenizer。常用的库:
- Anthropic 模型:官方没有公开 tokenizer,但可以用
@anthropic-ai/tokenizer(社区维护)做近似估算 - OpenAI 模型:
tiktoken,官方维护 - 通用方案:用字符数做粗略估算,中文大约 1 字 ≈ 1.5 token,英文大约 1 word ≈ 1.3 token
粗略估算的代码:
// token-estimate.ts
// 粗略估算 token 数,精确计算请换用对应模型的 tokenizer
function estimateTokens(text: string): number {
let count = 0
for (const char of text) {
if (char.charCodeAt(0) > 127) {
count += 1.5 // 非 ASCII(中文、日文等)
} else {
count += 0.25 // ASCII 字符
}
}
return Math.ceil(count)
}对 messages 数组做估算时,别忘了每条消息还有 role、分隔符等额外开销,大约 4 token/条,加上去就行。
Cloudflare Workers AI 会在服务端处理 token 限制,超出时返回错误。你可以在请求时加 max_tokens 参数限制输出长度,但输入长度只能自己控制。
6. 消息截断策略
当历史消息的 Token 数接近窗口上限时,需要丢掉一些消息。常见的截断策略有三种:
策略一:只保留最近 N 条
最简单的方式——取最新的 N 条消息,更早的直接丢掉。
// truncate.ts
import type { Message } from './types'
function keepLastN(messages: Message[], n: number): Message[] {
// system prompt 永远保留,不参与截断计数
const system = messages.filter((m) => m.role === 'system')
const nonSystem = messages.filter((m) => m.role !== 'system')
const kept = nonSystem.slice(-n)
return [...system, ...kept]
}优点是实现简单,缺点是可能把关键上下文丢掉——比如用户在第 3 轮提到的约束条件,到第 15 轮就被截掉了。
策略二:按 Token 预算截断
设一个 Token 预算(比如给输入留 60K tokens),从最新的消息往回累加,直到快超出预算就停。
// truncate.ts
function truncateByTokenBudget(
messages: Message[],
budgetTokens: number,
estimateTokens: (msgs: Message[]) => number
): Message[] {
const system = messages.filter((m) => m.role === 'system')
const nonSystem = messages.filter((m) => m.role !== 'system')
// 从最新的消息往回取,直到超出预算
const kept: Message[] = []
for (let i = nonSystem.length - 1; i >= 0; i--) {
const candidate = [nonSystem[i], ...kept]
if (estimateTokens([...system, ...candidate]) > budgetTokens) {
break
}
kept.unshift(nonSystem[i])
}
return [...system, ...kept]
}这种方式更精确——短消息可以保留更多条,长消息自动少保留几条。
策略三:摘要压缩
当历史很长、又不想丢关键信息时,可以把较早的消息调一次 LLM 生成摘要,用摘要替代原文:
原始历史:[msg1, msg2, msg3, msg4, msg5, msg6, msg7, msg8, msg9, msg10]
截断后: [摘要(msg1~msg5), msg6, msg7, msg8, msg9, msg10]
实现思路:取前一半消息,拼成一段文本,用 LLM 生成 3-5 句话的摘要,然后把摘要作为一条 system 消息放回数组开头。
// truncate.ts
async function summarizeAndTruncate(
messages: Message[],
budgetTokens: number,
ai: Ai
): Promise<Message[]> {
const system = messages.filter((m) => m.role === 'system')
const nonSystem = messages.filter((m) => m.role !== 'system')
const splitPoint = Math.floor(nonSystem.length / 2)
const toSummarize = nonSystem.slice(0, splitPoint)
const toKeep = nonSystem.slice(splitPoint)
const result = await ai.run('@cf/meta/llama-3.1-8b-instruct', {
messages: [
{ role: 'system', content: '用 3-5 句话总结以下对话的关键信息。' },
{ role: 'user', content: toSummarize.map((m) => `${m.role}: ${m.content}`).join('\n') },
],
})
const summaryMsg: Message = {
role: 'system',
content: `之前对话的摘要:${(result as { response: string }).response}`,
}
return [...system, summaryMsg, ...toKeep]
}摘要策略的成本最高(多调了一次 LLM),但保留的上下文质量也最好。对于对话轮数较多的场景,这个投入是值得的。
7. 完整的多轮对话接口
把前面的模块拼起来,写一个完整的多轮对话接口:
// src/routes/chat.ts
import { Hono } from 'hono'
import type { Message } from '../types'
import { getHistory, appendMessage } from '../lib/conversation-store'
import { truncateByTokenBudget, estimateMessagesTokens } from '../lib/truncate'
type Bindings = {
KV: KVNamespace
AI: Ai
OPENAI_API_KEY: string
}
const app = new Hono<{ Bindings: Bindings }>()
// 模型输入 Token 预算,留出空间给输出
const INPUT_TOKEN_BUDGET = 60_000
app.post('/api/chat', async (c) => {
const { sessionId, message, systemPrompt } = await c.req.json<{
sessionId: string
message: string
systemPrompt?: string
}>()
if (!sessionId || !message) {
return c.json({ error: 'sessionId and message are required' }, 400)
}
// 1. 读取历史消息
const history = await getHistory(c.env.KV, sessionId)
// 2. 如果是新会话的第一条消息,插入 system prompt
if (history.length === 0 && systemPrompt) {
await appendMessage(c.env.KV, sessionId, {
role: 'system',
content: systemPrompt,
})
history.push({ role: 'system', content: systemPrompt })
}
// 3. 把用户的新消息追加到历史
const userMsg: Message = { role: 'user', content: message }
await appendMessage(c.env.KV, sessionId, userMsg)
history.push(userMsg)
// 4. 截断历史,控制在 Token 预算内
const truncatedHistory = truncateByTokenBudget(
history,
INPUT_TOKEN_BUDGET,
estimateMessagesTokens
)
// 5. 调用 LLM
// 这里以 Cloudflare Workers AI 为例,换成 OpenAI API 也只是请求地址和格式不同
const result = await c.env.AI.run('@cf/meta/llama-3.1-8b-instruct', {
messages: truncatedHistory,
max_tokens: 2048,
})
const assistantContent = (result as { response: string }).response
// 6. 把 AI 的回答也存进历史
const assistantMsg: Message = { role: 'assistant', content: assistantContent }
await appendMessage(c.env.KV, sessionId, assistantMsg)
// 7. 返回结果
return c.json({
sessionId,
message: assistantContent,
})
})
export default app这个接口做了 7 件事:
- 从请求体里取
sessionId和message,缺参数返回 400 - 读 KV 拿历史消息;如果是新会话且前端传了
systemPrompt,先插入 system prompt - 把用户的新消息追加到 KV 的历史里
- 用 Token 预算截断历史,防止超出模型输入上限
- 把截断后的消息数组发给 LLM
- 把 AI 的回答也存进 KV,下一轮对话时它就是「历史」的一部分
- 把回答返回给前端
在入口文件里用 app.route('/api', chatApp) 注册这个路由模块就行。
8. 几个容易踩的坑
读-改-写的并发问题:appendMessage 是 读取 → 追加 → 写回。如果同一个会话的两条消息几乎同时到达,可能丢消息。用户量小时不会发生,真要解决可以用乐观锁或换用 Durable Objects(下一篇展开)。
不要把历史消息存在前端:有些设计让前端保存完整历史、每次全量上传。消息越传越多浪费带宽,而且前端可以篡改、刷新页面就丢了。历史消息存在服务端,前端只存一个 sessionId,更稳妥。
system prompt 不要重复插入:只在历史为空时插入,或者先检查历史里是否已经有 system 角色的消息。
LLM 调用失败时不要留半截历史:用户消息存了但 AI 回答没存,历史里最后一条是 user 消息没有 assistant 回复,某些模型处理不好。稳妥的做法是调用失败时不存用户消息,或者存一条失败的占位消息。
9. 会话管理的辅助接口
光有聊天接口还不够,前端还需要几个辅助接口:
// src/routes/conversations.ts
import { Hono } from 'hono'
import { randomUUID } from 'crypto'
type Bindings = {
KV: KVNamespace
}
const app = new Hono<{ Bindings: Bindings }>()
// 创建新会话
app.post('/api/conversations', (c) => {
const sessionId = randomUUID()
return c.json({ sessionId, createdAt: new Date().toISOString() })
})
// 获取会话历史(前端刷新页面后恢复对话用)
app.get('/api/conversations/:sessionId', async (c) => {
const sessionId = c.req.param('sessionId')
const raw = await c.env.KV.get(`conversation:${sessionId}`, 'json')
if (!raw) {
return c.json({ error: 'Conversation not found' }, 404)
}
return c.json({ sessionId, messages: raw })
})
// 删除会话(用户点「清除对话」)
app.delete('/api/conversations/:sessionId', async (c) => {
const sessionId = c.req.param('sessionId')
await c.env.KV.delete(`conversation:${sessionId}`)
return c.json({ success: true })
})
export default app这三个接口加上前面的 /api/chat,构成完整的多轮对话服务。
总结
这篇从单轮问答的局限出发,拆解了多轮对话接口的各个组成部分:
- LLM 没有记忆,多轮对话的本质是服务端把历史消息拼成
messages数组传给模型 - 会话 ID(UUID v4)把同一个对话的多条消息关联在一起
- KV 按
conversation:{sessionId}存完整的消息数组,设置 TTL 自动过期 - Token 预算控制历史长度,截断策略按需求从简到繁:最近 N 条 → Token 预算 → 摘要压缩
- 完整接口分 7 步:读历史 → 插 system prompt → 追加用户消息 → 截断 → 调 LLM → 存回答 → 返回
下一篇会聊到流式响应——等 LLM 生成完整回答再返回,用户要等很久。用 SSE(Server-Sent Events)把回答一个字一个字地推给前端,体验会好很多。