Appearance
课 2 · 接口设计与前端消费
本课目标
设计知识库 Agent 的核心接口,实现流式问答 SSE,并用最小前端页面消费 API。课后你会拿到一个可以在浏览器里提问的问答页面。
关键理解:流式接口和普通 JSON 接口本质上没有区别——只是返回方式从"一次性"变成了"逐片段"。
接口清单设计
进阶项目需要三类核心接口:
| 接口 | 路径 | 方法 | 说明 |
|---|---|---|---|
| 问答 | /api/chat | POST | 发送问题,返回答案 |
| 流式问答 | /api/chat/stream | POST | 发送问题,返回 SSE 流 |
| 上传文档 | /api/docs/upload | POST | 上传文件,触发导入任务 |
| 文档列表 | /api/docs | GET | 查询已导入文档 |
| 会话列表 | /api/sessions | GET | 查询历史会话 |
这节课先实现前两个最核心的接口,其余接口在后续模块逐步添加。
普通 JSON 接口
typescript
// apps/api/src/routes/chat.ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
import { agentService } from '../services/agent'
export const chatRouter = new Hono()
const askSchema = z.object({
message: z.string().min(1).max(2000),
sessionId: z.string().uuid().optional(),
})
// 普通接口:等待完整答案再返回
chatRouter.post('/', zValidator('json', askSchema), async (c) => {
const { message, sessionId } = c.req.valid('json')
const { answer, sources, sessionId: newSessionId } = await agentService.ask({
message,
sessionId,
})
return c.json({ answer, sources, sessionId: newSessionId })
})流式 SSE 接口
SSE(Server-Sent Events)是实现流式输出的最简单方式。浏览器原生支持,不需要 WebSocket。
typescript
import { streamSSE } from 'hono/streaming'
// 流式接口:逐 token 推送
chatRouter.post('/stream', zValidator('json', askSchema), async (c) => {
const { message, sessionId } = c.req.valid('json')
return streamSSE(c, async (stream) => {
const { textStream, sources } = await agentService.askStream({
message,
sessionId,
})
for await (const chunk of textStream) {
await stream.writeSSE({ data: JSON.stringify({ type: 'text', content: chunk }) })
}
// 最后推送引用来源
await stream.writeSSE({ data: JSON.stringify({ type: 'sources', content: sources }) })
await stream.writeSSE({ data: JSON.stringify({ type: 'done' }) })
})
})SSE 数据格式约定
data: {"type":"text","content":"这"}
data: {"type":"text","content":"个知"}
data: {"type":"text","content":"识库"}
data: {"type":"sources","content":[{"title":"...","page":1}]}
data: {"type":"done"}约定 type 字段区分内容类型,前端根据类型决定如何渲染。
前端消费流式接口
typescript
// apps/web/src/lib/chat.ts
export async function askStream(
message: string,
sessionId: string | undefined,
onChunk: (text: string) => void,
onDone: (sources: Source[]) => void,
) {
const res = await fetch('/api/chat/stream', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, sessionId }),
})
const reader = res.body!.getReader()
const decoder = new TextDecoder()
while (true) {
const { done, value } = await reader.read()
if (done) break
const text = decoder.decode(value)
// SSE 格式:每行 "data: {...}\n\n"
for (const line of text.split('\n')) {
if (!line.startsWith('data: ')) continue
const payload = JSON.parse(line.slice(6))
if (payload.type === 'text') onChunk(payload.content)
if (payload.type === 'sources') onDone(payload.content)
}
}
}最小问答页面
进阶课的前端只需要一个功能页面,不需要复杂 UI 框架:
html
<!-- apps/web/index.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>知识库问答</title>
</head>
<body>
<div id="chat">
<div id="messages"></div>
<form id="form">
<input id="input" placeholder="输入问题..." />
<button type="submit">发送</button>
</form>
</div>
<script type="module" src="./src/main.ts"></script>
</body>
</html>typescript
// apps/web/src/main.ts
import { askStream } from './lib/chat'
const form = document.getElementById('form')!
const input = document.getElementById('input') as HTMLInputElement
const messages = document.getElementById('messages')!
form.addEventListener('submit', async (e) => {
e.preventDefault()
const message = input.value.trim()
if (!message) return
input.value = ''
appendMessage('user', message)
const answerEl = appendMessage('assistant', '')
await askStream(
message,
undefined,
(chunk) => { answerEl.textContent += chunk },
(sources) => {
const sourceEl = document.createElement('div')
sourceEl.textContent = `来源:${sources.map((s) => s.title).join('、')}`
answerEl.after(sourceEl)
},
)
})
function appendMessage(role: string, text: string) {
const el = document.createElement('p')
el.dataset.role = role
el.textContent = text
messages.appendChild(el)
return el
}本节产物
apps/api/src/routes/
chat.ts # 普通问答 + 流式 SSE 接口
docs.ts # 文档上传接口(skeleton)
apps/web/
index.html
src/
main.ts # 简单问答页
lib/
chat.ts # fetch + SSE 流消费工具面试追问
流式接口和普通 JSON 接口有什么区别?
普通接口等 Agent 生成完整答案后一次性返回,用户要等几秒才看到内容。流式接口每生成一个 token 就推送一次,用户立刻看到内容在逐字出现,体验更好。实现上,流式接口用 SSE(Server-Sent Events)协议,前端用 ReadableStream 消费;普通接口就是普通的 async/await + res.json()。
前端如何处理 SSE 断开重连?
生产环境可以用 EventSource API 代替手写 ReadableStream,它内置断线重连。或者在 fetch 层加 retry 逻辑。进阶课保持演示用 fetch 手写,让原理更清晰。