Skip to content

课 2 · 接口设计与前端消费

本课目标

设计知识库 Agent 的核心接口,实现流式问答 SSE,并用最小前端页面消费 API。课后你会拿到一个可以在浏览器里提问的问答页面。

关键理解:流式接口和普通 JSON 接口本质上没有区别——只是返回方式从"一次性"变成了"逐片段"。

接口清单设计

进阶项目需要三类核心接口:

接口路径方法说明
问答/api/chatPOST发送问题,返回答案
流式问答/api/chat/streamPOST发送问题,返回 SSE 流
上传文档/api/docs/uploadPOST上传文件,触发导入任务
文档列表/api/docsGET查询已导入文档
会话列表/api/sessionsGET查询历史会话

这节课先实现前两个最核心的接口,其余接口在后续模块逐步添加。

普通 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 手写,让原理更清晰。

面向前端工程师和独立开发者的 AI 应用工程课程