聊天接口设计

要点

  • 聊天接口和普通 REST API 不同——响应耗时长、长度不确定、需要维护对话上下文
  • 后端中间层负责屏蔽供应商差异、管理对话历史、控制 Token 用量
  • 请求体核心字段是 modelmessagesstream
  • 消息内容不只是字符串,还支持图片、工具调用等多种类型
  • 会话管理有两种思路:前端传完整历史,或服务端存储 + session_id
  • 上下文窗口有限,服务端需要在发送前做 Token 截断
  • 错误处理要同时覆盖非流式和流式两种场景

1. 什么是聊天接口

前面几篇介绍了怎么用 Hono 搭 REST API。现在我们来看一种不太一样的接口。

普通 REST 接口:前端发请求,后端查数据库,返回 JSON。整个过程几百毫秒,响应几 KB 到几十 KB。

聊天接口不一样:

  • 响应可能要等几秒到几十秒
  • 可能是流式的,逐字推给前端
  • 请求里带的是整段对话历史
  • 响应长度不确定

所以聊天接口的设计需要解决几个 REST 接口不太关心的问题:上下文管理、流式传输、Token 控制、长耗时下的错误处理。

2. 为什么不直接调大模型 API

也许你会问:前端直接调 OpenAI、Anthropic 的 API 不就行了?直接调有几个问题:

  1. API Key 暴露 — 密钥放前端等于公开给所有人
  2. 无法管理历史 — 前端刷新后对话记录就丢了
  3. 无法控制用量 — 没有中间层做计量限流
  4. 和供应商绑死 — 换模型供应商前端也要改

在后端加一层聊天服务可以解决这些问题。API Key 存服务端环境变量,对话历史存数据库,中间层做限流计量,前端只跟后端接口打交道。

3. 请求和响应数据结构

请求体

// src/types/chat.ts
export type ChatRequest = {
  model: string          // 选择哪个模型
  messages: Message[]    // 对话历史,按时间顺序
  stream?: boolean       // 是否流式响应(SSE)
  temperature?: number   // 随机性,0-2
  max_tokens?: number    // 最大输出长度
  system_prompt?: string // 系统提示词
  tools?: Tool[]         // 可用工具列表
}

一个真实请求:

// request.json
{
  "model": "gpt-4o",
  "messages": [
    { "role": "user", "content": "你好,我想学习 Hono" },
    { "role": "assistant", "content": "好的!Hono 是一个轻量级框架..." },
    { "role": "user", "content": "它和 Express 有什么区别?" }
  ],
  "stream": false, "temperature": 0.7
}

每次请求都带上完整对话历史。模型需要上下文才能生成连贯回复。这个设计让接口保持无状态(后面第 5 节会讨论有状态的方案)。

非流式响应

// src/types/chat.ts
export type ChatResponse = {
  model: string
  choices: {
    index: number
    message: { role: 'assistant'; content: string }
    finish_reason: 'stop' | 'length' | 'tool_calls' | 'content_filter'
  }[]
  usage: { prompt_tokens: number; completion_tokens: number; total_tokens: number }
}

finish_reasonstop 正常结束,length 达到 max_tokens 上限,tool_calls 模型要调用工具。

流式响应

流式模式下,服务端用 SSE 逐块推送:

data: {"choices":[{"delta":{"content":"Hono"}}]}
 
data: {"choices":[{"delta":{"content":" 和"}}]}
 
data: {"choices":[{"delta":{},"finish_reason":"stop"}]}
 
data: [DONE]

每个 data: 行是一个 SSE 事件,delta 是增量内容。[DONE] 是结束标记。

4. 消息类型

content 不只是字符串。它可以是一个「内容块」数组,支持多种类型:

// src/types/message.ts
export type ContentPart =
  | { type: 'text'; text: string }
  | { type: 'image_url'; image_url: { url: string } }
 
export type Message = {
  role: 'system' | 'user' | 'assistant' | 'tool'
  content: string | ContentPart[]
  tool_calls?: ToolCall[]   // assistant 消息的工具调用
  tool_call_id?: string     // tool 消息关联的调用 ID
}

纯文本

{ "role": "user", "content": "帮我写一个 Hello World" }

图片

content 改成数组,文本和图片放在一起:

{
  "role": "user",
  "content": [
    { "type": "text", "text": "这张图片里有什么?" },
    { "type": "image_url", "image_url": { "url": "https://example.com/photo.jpg" } }
  ]
}

工具调用(Function Calling)

模型可以「调用函数」。流程:

  1. 告诉模型有哪些函数可用,以及参数格式
  2. 模型判断需要调用时,回复里带上函数名和参数
  3. 你的代码执行函数,拿到结果
  4. 把结果作为 tool 消息发回给模型
  5. 模型根据结果继续生成
// src/types/tool.ts
// 工具定义
export type Tool = {
  type: 'function'
  function: { name: string; description: string; parameters: Record<string, unknown> }
}
 
// 模型返回的工具调用
export type ToolCall = {
  id: string
  type: 'function'
  function: { name: string; arguments: string } // arguments 是 JSON 字符串
}

工具调用的消息流:

// assistant 发起调用
{ "role": "assistant", "content": null,
  "tool_calls": [{ "id": "call_abc", "function": { "name": "get_weather", "arguments": "{\"city\":\"北京\"}" } }] }
 
// tool 返回结果
{ "role": "tool", "tool_call_id": "call_abc", "content": "{\"temp\":\"22°C\"}" }

注意 arguments 是 JSON 字符串,需要 JSON.parse 之后才能用。

System 消息

system 角色是给模型的指令,放在对话最前面:

{ "role": "system", "content": "你是一个专业的 Hono 开发助手,回答要简洁。" }

5. 会话管理

每次请求都带完整历史,对话越来越长之后请求体会很大。而且前端刷新后对话就丢了。

更常见的做法是在服务端存储历史,前端只传 session_id

// src/routes/chat.ts
import { Hono } from 'hono'
import type { AppEnv } from '../types'
 
export const chatApp = new Hono<AppEnv>()
 
// 创建新会话
chatApp.post('/sessions', async (c) => {
  const body = await c.req.json()
  const sessionId = crypto.randomUUID()
  const userId = c.get('user').id
 
  await c.env.DB.prepare(
    'INSERT INTO sessions (id, user_id, model, created_at) VALUES (?, ?, ?, ?)'
  ).bind(sessionId, userId, body.model, Date.now()).run()
 
  await c.env.DB.prepare(
    'INSERT INTO messages (session_id, role, content, created_at) VALUES (?, ?, ?, ?)'
  ).bind(sessionId, 'user', body.message, Date.now()).run()
 
  return c.json({ session_id: sessionId })
})
 
// 继续已有会话 — 查询历史,拼接新消息,调用模型
chatApp.post('/sessions/:sessionId/messages', async (c) => {
  const sessionId = c.req.param('sessionId')
  const body = await c.req.json()
 
  const { results: history } = await c.env.DB
    .prepare('SELECT role, content FROM messages WHERE session_id = ? ORDER BY created_at ASC')
    .bind(sessionId).all()
 
  const messages = [...history, { role: 'user', content: body.message }]
  // 调用模型...
})

存储方案选择:

  • D1(SQLite):适合结构化查询,消息按会话分页,支持排序 — 多数情况下的首选
  • KV:读取延迟低,但每个会话只能存一条记录,读-改-写不是原子的,并发容易丢数据
  • R2:适合大文件,对结构化查询没有优势

6. 上下文窗口与 Token 限制

大模型有最大输入 Token 数(4K、8K、128K 等),超过就报错或丢弃内容。

服务端需要在发给模型前做截断。两种策略:

策略一:保留最近 N 条 — 注意 system 消息始终保留:

// src/utils/context.ts
export function truncateMessages(messages: Message[], maxMessages: number): Message[] {
  const system = messages.filter((m) => m.role === 'system')
  const rest = messages.filter((m) => m.role !== 'system')
  return [...system, ...rest.slice(-maxMessages)]
}

按 Token 数估算 — 从最新消息往前累加,接近上限时停止:

// src/utils/context.ts
// 粗略估算:中文约 1 字 = 1.5 token,英文约 1 词 = 1.3 token
function estimateTokens(text: string): number {
  const cjk = (text.match(/[一-鿿]/g) || []).length
  return Math.ceil(cjk * 1.5 + (text.length - cjk) * 1.3)
}
 
export function buildContextWindow(messages: Message[], maxTokens: number, reserved: number = 1000): Message[] {
  const budget = maxTokens - reserved
  const result: Message[] = []
  let total = 0
 
  // system 消息始终保留
  for (const msg of messages) {
    if (msg.role === 'system') {
      result.push(msg)
      total += estimateTokens(typeof msg.content === 'string' ? msg.content : '')
    }
  }
 
  // 从最新往前累加
  const nonSystem = messages.filter((m) => m.role !== 'system')
  for (let i = nonSystem.length - 1; i >= 0; i--) {
    const msg = nonSystem[i]
    const content = typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content)
    const tokens = estimateTokens(content)
    if (total + tokens > budget) break
    result.unshift(msg)
    total += tokens
  }
 
  return result
}

效果是最近的对话总是保留,最早的历史逐渐丢弃。消息被丢弃时,更好的做法是返回一个提示,告诉用户对话已超出长度限制。

Token 估算比较粗糙,生产环境建议用 tiktoken 等专门的库做精确计数。粗略估算在大部分场景下够用,但对成本敏感的场景需要精确计数。

7. 错误处理

聊天接口的错误类型比普通 API 多:参数错误、认证失败、限流、Token 超限、上游超时。

非流式响应直接返回错误 JSON:

// src/routes/chat.ts
chatApp.post('/chat', async (c) => {
  try {
    const body = await c.req.json()
 
    if (!body.model || !body.messages?.length) {
      return c.json({ error: { code: 'INVALID_REQUEST', message: '缺少必要参数' } }, 400)
    }
 
    const result = await callModelAPI(body)
    return c.json(result)
  } catch (err) {
    if (err instanceof TokenLimitError) {
      return c.json({ error: { code: 'TOKEN_LIMIT', message: '消息总长度超出上下文窗口' } }, 400)
    }
    if (err instanceof UpstreamTimeoutError) {
      return c.json({ error: { code: 'UPSTREAM_TIMEOUT', message: '模型服务响应超时' } }, 504)
    }
    return c.json({ error: { code: 'INTERNAL_ERROR', message: '服务器内部错误' } }, 500)
  }
})

流式响应中途出错,不能改 HTTP 状态码,要在流里插入错误事件:

// src/routes/chat.ts
// 流式响应中途出错
;(async () => {
  try {
    for await (const chunk of stream) {
      await writer.write(encoder.encode(`data: ${JSON.stringify(chunk)}\n\n`))
    }
    await writer.write(encoder.encode('data: [DONE]\n\n'))
  } catch (err) {
    // 推送错误事件
    const errorData = JSON.stringify({ error: { code: 'STREAM_ERROR', message: '生成中断' } })
    await writer.write(encoder.encode(`data: ${errorData}\n\n`))
  } finally {
    await writer.close()
  }
})()

前端解析 SSE 时要判断事件是否包含 error 字段。

限流也要处理——模型调用成本高,不做限流容易刷爆额度:

// src/middleware/rate-limit.ts
export const rateLimit = async (c: any, next: () => Promise<void>) => {
  const key = `rate:${c.get('user').id}`
  const count = parseInt((await c.env.KV.get(key)) || '0')
  if (count >= 10) {
    return c.json({ error: { code: 'RATE_LIMITED', message: '请求过于频繁' } }, 429)
  }
  await c.env.KV.put(key, String(count + 1), { expirationTtl: 60 })
  await next()
}

8. 完整示例

// src/routes/chat.ts — 核心流程
chatApp.post('/chat', authMiddleware, rateLimit, async (c) => {
  const body = await c.req.json()
  const { model, messages, stream, temperature, max_tokens, system_prompt } = body
 
  if (!model || !messages?.length) {
    return c.json({ error: { code: 'INVALID_REQUEST', message: '缺少必要参数' } }, 400)
  }
 
  // Token 截断 + 拼接 system prompt
  const context = buildContextWindow(messages, getModelContextLimit(model))
  if (system_prompt) context.unshift({ role: 'system', content: system_prompt })
 
  try {
    const result = await callModelAPI({ model, messages: context, temperature, max_tokens })
 
    if (stream) {
      return createSSEResponse(result) // 见第 7 节流式处理
    }
    return c.json(result)
  } catch (err) {
    const msg = err instanceof Error ? err.message : '模型调用失败'
    return c.json({ error: { code: 'MODEL_ERROR', message: msg } }, 502)
  }
})

这个骨架覆盖了认证、限流、参数校验、上下文截断、流式/非流式响应、错误处理。每一块在前面都单独讲过。

总结

回顾一下这篇的要点:

  • 聊天接口和普通 REST API 有本质区别——耗时长、长度不定、需要上下文
  • 后端中间层负责安全、历史管理、用量控制和供应商适配
  • 请求体核心是 modelmessagesstream
  • 消息内容支持文本、图片、工具调用等多种类型
  • 会话管理可以用服务端存储 + session_id
  • 上下文窗口有限,需要做 Token 截断
  • 错误处理要覆盖非流式和流式两种场景

下一篇介绍流式响应的具体实现。