聊天接口设计
要点
- 聊天接口和普通 REST API 不同——响应耗时长、长度不确定、需要维护对话上下文
- 后端中间层负责屏蔽供应商差异、管理对话历史、控制 Token 用量
- 请求体核心字段是
model、messages和stream - 消息内容不只是字符串,还支持图片、工具调用等多种类型
- 会话管理有两种思路:前端传完整历史,或服务端存储 +
session_id - 上下文窗口有限,服务端需要在发送前做 Token 截断
- 错误处理要同时覆盖非流式和流式两种场景
1. 什么是聊天接口
前面几篇介绍了怎么用 Hono 搭 REST API。现在我们来看一种不太一样的接口。
普通 REST 接口:前端发请求,后端查数据库,返回 JSON。整个过程几百毫秒,响应几 KB 到几十 KB。
聊天接口不一样:
- 响应可能要等几秒到几十秒
- 可能是流式的,逐字推给前端
- 请求里带的是整段对话历史
- 响应长度不确定
所以聊天接口的设计需要解决几个 REST 接口不太关心的问题:上下文管理、流式传输、Token 控制、长耗时下的错误处理。
2. 为什么不直接调大模型 API
也许你会问:前端直接调 OpenAI、Anthropic 的 API 不就行了?直接调有几个问题:
- API Key 暴露 — 密钥放前端等于公开给所有人
- 无法管理历史 — 前端刷新后对话记录就丢了
- 无法控制用量 — 没有中间层做计量限流
- 和供应商绑死 — 换模型供应商前端也要改
在后端加一层聊天服务可以解决这些问题。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_reason:stop 正常结束,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)
模型可以「调用函数」。流程:
- 告诉模型有哪些函数可用,以及参数格式
- 模型判断需要调用时,回复里带上函数名和参数
- 你的代码执行函数,拿到结果
- 把结果作为
tool消息发回给模型 - 模型根据结果继续生成
// 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 有本质区别——耗时长、长度不定、需要上下文
- 后端中间层负责安全、历史管理、用量控制和供应商适配
- 请求体核心是
model、messages、stream - 消息内容支持文本、图片、工具调用等多种类型
- 会话管理可以用服务端存储 +
session_id - 上下文窗口有限,需要做 Token 截断
- 错误处理要覆盖非流式和流式两种场景
下一篇介绍流式响应的具体实现。