重构端到端 AI Chat
要点
- 延续 4.monorepo 章的组织方式
- 核心契约继续用 Zod 写,和前几章完全一致
内容
1. 本篇目标
前面我们做过一个端到端 AI Chat demo:
- 共享 Zod schema 作为契约
- Hono RPC Client 发请求
- Hono API 入口 validate
- 业务层调 LLM(手写 fetch、手写 SSE 解析、手写 JSON schema 校验)
- 响应层 schema 解析返回
那份代码证明了「Zod 作为唯一真相源」的威力。但它的流式链路是手搓的,前端也没有 useChat 的体验。
这一篇要做的事:用 AI SDK + 前面所有能力,把同一个 demo 重构一遍。最终成果是一个生产级别的、类型安全的、流式渲染的、带工具调用可视化的、能部署到 Cloudflare Workers 的端到端 AI Chat。
完整代码在 monorepo 里,本文展示关键片段和架构决策。
2. 项目结构
延续 4.monorepo 章的组织方式:
packages
shared 前后端共享
src
schemas
chat.ts 请求/响应 schema
companion.ts 业务 schema(情绪、记忆)
common.ts 通用类型
prompts
companion.ts Prompt 构造(Prompt 工程 × AI SDK)
models
providers.ts Provider 抽象(模型 Provider 生态)
api Cloudflare Workers 后端
src
index.ts Hono app 入口
bindings.ts Env 类型
routes
chat.ts /chat 接口
tools
search-memory.ts
update-emotion.ts
index.ts
middleware
cache.ts 缓存、限流、Fallback
rate-limit.ts
fallback.ts
telemetry
langfuse.ts 可观测性:Telemetry
web Next.js 16 前端
src
app
chat
page.tsx
components
chat.tsx useChat
message.tsx UI Parts 分发
text-part.tsx Markdown + 光标
reasoning-part.tsx
tool-parts
memory-card.tsx
emotion-badge.tsx
data-parts
emotion-badge.tsx
3. 共享 schema(契约层)
核心契约继续用 Zod 写,和前几章完全一致:
// shared/schemas/chat.ts
import { z } from 'zod'
export const ChatRequestSchema = z.object({
sessionId: z.string().min(1),
messages: z.array(z.object({
id: z.string(),
role: z.enum(['user', 'assistant', 'system']),
parts: z.array(z.unknown()),
})).min(1),
})
export type ChatRequest = z.infer<typeof ChatRequestSchema>// shared/schemas/companion.ts
import { z } from 'zod'
export const EmotionSchema = z.object({
primary: z.enum(['happy', 'sad', 'angry', 'calm', 'neutral']),
intensity: z.number().min(0).max(1),
})
export type Emotion = z.infer<typeof EmotionSchema>
export const MemorySchema = z.object({
id: z.string(),
content: z.string(),
relevance: z.number(),
tags: z.array(z.string()),
})
export type Memory = z.infer<typeof MemorySchema>data part 的泛型类型:
// shared/schemas/data-parts.ts
import type { Emotion } from './companion'
export type CompanionDataParts = {
emotion: Emotion
'memories-used': { count: number }
}这些 schema 前后端都 import。
4. 后端:Hono + AI SDK 主接口
4.1 入口
// api/src/index.ts
import { Hono } from 'hono'
import { cors } from 'hono/cors'
import chatRoute from './routes/chat'
import type { AppBindings } from './bindings'
const app = new Hono<AppBindings>()
app.use('*', cors({ origin: ['https://companion.yourdomain.com'] }))
app.route('/api', chatRoute)
export default app4.2 /chat 路由
// api/src/routes/chat.ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { streamText, convertToModelMessages } from 'ai'
import { ChatRequestSchema } from '@shared/schemas/chat'
import { buildCompanionModel } from '../models'
import { buildCompanionTools } from '../tools'
import { buildSystemPrompt } from '@shared/prompts/companion'
import type { AppBindings } from '../bindings'
const chatRoute = new Hono<AppBindings>()
chatRoute.post('/chat', zValidator('json', ChatRequestSchema), async (c) => {
const { sessionId, messages } = c.req.valid('json')
// 1. 加载上下文
const profile = await loadUserProfile(c.env.DB, sessionId)
const memories = await searchMemories(c.env, sessionId, messages)
// 2. 构造 system prompt
const system = buildSystemPrompt({
userNickname: profile.nickname,
personalityTags: profile.tags,
intimacy: profile.intimacy,
recentEmotions: profile.recentEmotions,
memories,
currentTime: new Date().toISOString(),
})
// 3. 构造带中间件的 model
const model = buildCompanionModel(c.env, sessionId, profile.userId)
// 4. streamText 驱动
const result = streamText({
model,
system,
messages: convertToModelMessages(messages as any),
tools: buildCompanionTools(c.env, sessionId),
stopWhen: stepCountIs(5),
abortSignal: c.req.raw.signal,
experimental_telemetry: {
isEnabled: true,
functionId: 'companion-chat',
metadata: {
sessionId,
userId: profile.userId,
'prompt.version': '[email protected]',
},
},
onFinish: ({ text, usage }) => {
c.executionCtx.waitUntil(
Promise.all([
saveAssistantMessage(c.env.DB, sessionId, text),
updateIntimacy(c.env.DB, sessionId, usage),
extractAndStoreMemories(c.env, sessionId, text),
]),
)
},
onAbort: ({ steps }) => {
c.executionCtx.waitUntil(
saveAssistantMessage(c.env.DB, sessionId, {
text: steps.map((s) => s.text).join(''),
status: 'aborted',
}),
)
},
})
return result.toUIMessageStreamResponse({
// 往 UIMessage.metadata 注入业务数据
messageMetadata: ({ part }) => {
if (part.type === 'finish') {
return {
sessionId,
intimacy: profile.intimacy,
}
}
},
})
})
export default chatRoute4.3 tools
// api/src/tools/search-memory.ts
import { tool } from 'ai'
import { z } from 'zod'
export function searchMemoryTool(env: Env, sessionId: string) {
return tool({
description: '从长期记忆库检索相关回忆,每条包含内容、相似度和标签。',
inputSchema: z.object({
query: z.string().describe('检索关键词'),
topK: z.number().int().min(1).max(10).default(3),
}),
execute: async ({ query, topK }, { abortSignal }) => {
const embedding = await env.AI.run('@cf/baai/bge-m3', { text: [query] })
const { matches } = await env.VECTORIZE.query(embedding.data[0], {
topK,
filter: { sessionId },
returnMetadata: true,
})
return matches.map((m) => ({
id: m.id,
content: m.metadata?.content,
relevance: m.score,
tags: m.metadata?.tags ?? [],
}))
},
})
}// api/src/tools/update-emotion.ts
import { tool } from 'ai'
import { EmotionSchema } from '@shared/schemas/companion'
export function updateEmotionTool(env: Env, sessionId: string) {
return tool({
description: '记录用户当前情绪到数据库。',
inputSchema: EmotionSchema,
execute: async ({ primary, intensity }) => {
await env.DB.prepare(
'INSERT INTO emotion_logs (session_id, emotion, intensity, created_at) VALUES (?,?,?,?)',
).bind(sessionId, primary, intensity, Date.now()).run()
return { success: true, emotion: primary }
},
})
}// api/src/tools/index.ts
export function buildCompanionTools(env: Env, sessionId: string) {
return {
searchMemory: searchMemoryTool(env, sessionId),
updateEmotion: updateEmotionTool(env, sessionId),
}
}4.4 带中间件的 model
// api/src/models.ts
import { wrapLanguageModel } from 'ai'
import { createWorkersAI } from 'workers-ai-provider'
import { createOpenAI } from '@ai-sdk/openai'
import { rateLimitMiddleware } from './middleware/rate-limit'
import { fallbackMiddleware } from './middleware/fallback'
export function buildCompanionModel(env: Env, sessionId: string, userId: string) {
const workersai = createWorkersAI({ binding: env.AI })
const primary = workersai('@cf/meta/llama-3.3-70b-instruct-fp8-fast')
const openai = createOpenAI({ apiKey: env.OPENAI_API_KEY })
const fallback = openai('gpt-4o-mini')
return wrapLanguageModel({
model: primary,
middleware: [
rateLimitMiddleware({
kv: env.KV,
keyFn: () => userId,
limit: 120,
windowSec: 60,
}),
fallbackMiddleware(fallback),
],
})
}5. 前端:Next.js + useChat
5.1 页面
// web/src/app/chat/page.tsx
import { ChatClient } from '@/components/chat'
export default function ChatPage() {
return (
<main className="mx-auto max-w-180 py-8">
<ChatClient />
</main>
)
}5.2 Chat 组件
// web/src/components/chat.tsx
'use client'
import { useChat } from '@ai-sdk/react'
import { DefaultChatTransport } from 'ai'
import { useState } from 'react'
import { Message } from './message'
import type { CompanionDataParts } from '@shared/schemas/data-parts'
import type { UIMessage } from 'ai'
type CompanionMessage = UIMessage<{ sessionId: string; intimacy: number }, CompanionDataParts>
export function ChatClient() {
const [input, setInput] = useState('')
const sessionId = useSessionId()
const { messages, sendMessage, status, stop, regenerate, error } =
useChat<CompanionMessage>({
transport: new DefaultChatTransport({
api: '/api/chat',
prepareSendMessagesRequest: ({ messages, id }) => ({
body: { sessionId, messages },
}),
}),
})
return (
<div className="flex flex-col gap-4">
<ul className="flex flex-col gap-3">
{messages.map((m) => <Message key={m.id} message={m} />)}
</ul>
{status === 'streaming' && (
<button onClick={stop} className="text-xs text-gray-500">
停止生成
</button>
)}
{error && (
<div className="text-red-500">
出错:{error.message}
<button onClick={() => regenerate()} className="ml-2 underline">
重试
</button>
</div>
)}
<form
onSubmit={(e) => {
e.preventDefault()
if (!input.trim()) return
sendMessage({ text: input })
setInput('')
}}
className="flex gap-2"
>
<input
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="说点什么..."
className="flex-1 rounded-md border px-3 py-2"
disabled={status !== 'ready'}
/>
<button
type="submit"
disabled={status !== 'ready'}
className="rounded-md bg-black px-4 py-2 text-white disabled:opacity-50"
>
发送
</button>
</form>
</div>
)
}5.3 Message 组件(UI Parts 分发)
// web/src/components/message.tsx
import type { UIMessage } from 'ai'
import { TextPart } from './text-part'
import { ReasoningPart } from './reasoning-part'
import { MemoryCard } from './tool-parts/memory-card'
import { EmotionBadge } from './data-parts/emotion-badge'
export function Message({ message }: { message: UIMessage }) {
return (
<li className={`message message--${message.role}`}>
<div className="role-label">{message.role === 'user' ? '你' : '小舟'}</div>
<div className="body">
{message.parts.map((p, i) => {
if (p.type === 'text') return <TextPart key={i} part={p} />
if (p.type === 'reasoning') return <ReasoningPart key={i} part={p} />
if (p.type === 'data-emotion')
return <EmotionBadge key={i} data={p.data} />
if (p.type === 'tool-searchMemory' && p.state === 'output-available')
return <MemoryCard key={i} memories={p.output} />
return null
})}
</div>
</li>
)
}其他 part 组件按之前的模式实现,不一一展开。