多轮对话接口

要点

  • 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,拿回回答就结束。没有历史,没有上下文。

这种模式能跑通,但用户体验有明显的问题:

  1. 用户问「它有什么优缺点?」——「它」指的是什么?上一次问的 TypeScript。但服务端不知道
  2. 用户说「换一种更简单的解释」——更简单是相对于谁的回答?上一次的。但上一次的内容服务端已经丢了
  3. 用户想围绕同一个话题连续讨论,每次都要把前因后果重新描述一遍

把视线收回到 LLM 的调用方式,会发现一个关键事实:LLM 没有记忆。它不是「聊着聊着就记住了」,而是每次请求都要把所有信息完整传过去。所谓「多轮对话」,本质上是服务端帮 LLM 把历史消息拼起来。

2. messages 数组:LLM 的输入格式

LLM 的输入不是纯文本字符串,而是一个结构化的 messages 数组。每个元素有三个字段:

  • role:这条消息是谁说的。常见取值:systemuserassistant
  • content:消息的文本内容
  • (部分模型支持更多 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 件事:

  1. 从请求体里取 sessionIdmessage,缺参数返回 400
  2. 读 KV 拿历史消息;如果是新会话且前端传了 systemPrompt,先插入 system prompt
  3. 把用户的新消息追加到 KV 的历史里
  4. 用 Token 预算截断历史,防止超出模型输入上限
  5. 把截断后的消息数组发给 LLM
  6. 把 AI 的回答也存进 KV,下一轮对话时它就是「历史」的一部分
  7. 把回答返回给前端

在入口文件里用 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:&#123;sessionId&#125; 存完整的消息数组,设置 TTL 自动过期
  • Token 预算控制历史长度,截断策略按需求从简到繁:最近 N 条 → Token 预算 → 摘要压缩
  • 完整接口分 7 步:读历史 → 插 system prompt → 追加用户消息 → 截断 → 调 LLM → 存回答 → 返回

下一篇会聊到流式响应——等 LLM 生成完整回答再返回,用户要等很久。用 SSE(Server-Sent Events)把回答一个字一个字地推给前端,体验会好很多。