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 章的组织方式:
chat.ts请求/响应 schema
companion.ts业务 schema(情绪、记忆)
common.ts通用类型
companion.tsPrompt 构造(Prompt 工程 × AI SDK)
providers.tsProvider 抽象(模型 Provider 生态)
index.tsHono app 入口
bindings.tsEnv 类型
chat.ts/chat 接口
search-memory.ts
update-emotion.ts
index.ts
cache.ts缓存、限流、Fallback
rate-limit.ts
fallback.ts
langfuse.ts可观测性:Telemetry
page.tsx
chat.tsxuseChat
message.tsxUI Parts 分发
text-part.tsxMarkdown + 光标
reasoning-part.tsx
memory-card.tsx
emotion-badge.tsx
emotion-badge.tsx
3. 共享 schema(契约层)
核心契约继续用 Zod 写,和前几章完全一致:
shared/schemas/chat.ts
01import { z } from 'zod'0203export const ChatRequestSchema = z.object({04sessionId: z.string().min(1),05messages: z.array(z.object({06id: z.string(),07role: z.enum(['user', 'assistant', 'system']),08parts: z.array(z.unknown()),09})).min(1),10})1112export type ChatRequest = z.infer<typeof ChatRequestSchema>
shared/schemas/companion.ts
01import { z } from 'zod'0203export const EmotionSchema = z.object({04primary: z.enum(['happy', 'sad', 'angry', 'calm', 'neutral']),05intensity: z.number().min(0).max(1),06})07export type Emotion = z.infer<typeof EmotionSchema>0809export const MemorySchema = z.object({10id: z.string(),11content: z.string(),12relevance: z.number(),13tags: z.array(z.string()),14})15export type Memory = z.infer<typeof MemorySchema>
data part 的泛型类型:
shared/schemas/data-parts.ts
1import type { Emotion } from './companion'23export type CompanionDataParts = {4emotion: Emotion5'memories-used': { count: number }6}
这些 schema 前后端都 import。
4. 后端:Hono + AI SDK 主接口
4.1 入口
api/src/index.ts
01import { Hono } from 'hono'02import { cors } from 'hono/cors'03import chatRoute from './routes/chat'04import type { AppBindings } from './bindings'0506const app = new Hono<AppBindings>()0708app.use('*', cors({ origin: ['https://companion.yourdomain.com'] }))09app.route('/api', chatRoute)1011export default app
4.2 /chat 路由
api/src/routes/chat.ts
01import { Hono } from 'hono'02import { zValidator } from '@hono/zod-validator'03import { streamText, convertToModelMessages } from 'ai'04import { ChatRequestSchema } from '@shared/schemas/chat'05import { buildCompanionModel } from '../models'06import { buildCompanionTools } from '../tools'07import { buildSystemPrompt } from '@shared/prompts/companion'08import type { AppBindings } from '../bindings'0910const chatRoute = new Hono<AppBindings>()1112chatRoute.post('/chat', zValidator('json', ChatRequestSchema), async (c) => {13const { sessionId, messages } = c.req.valid('json')1415// 1. 加载上下文16const profile = await loadUserProfile(c.env.DB, sessionId)17const memories = await searchMemories(c.env, sessionId, messages)1819// 2. 构造 system prompt20const system = buildSystemPrompt({21userNickname: profile.nickname,22personalityTags: profile.tags,23intimacy: profile.intimacy,24recentEmotions: profile.recentEmotions,25memories,26currentTime: new Date().toISOString(),27})2829// 3. 构造带中间件的 model30const model = buildCompanionModel(c.env, sessionId, profile.userId)3132// 4. streamText 驱动33const result = streamText({34model,35system,36messages: convertToModelMessages(messages as any),37tools: buildCompanionTools(c.env, sessionId),38stopWhen: stepCountIs(5),39abortSignal: c.req.raw.signal,4041experimental_telemetry: {42isEnabled: true,43functionId: 'companion-chat',44metadata: {45sessionId,46userId: profile.userId,47'prompt.version': 'companion@v1.3',48},49},5051onFinish: ({ text, usage }) => {52c.executionCtx.waitUntil(53Promise.all([54saveAssistantMessage(c.env.DB, sessionId, text),55updateIntimacy(c.env.DB, sessionId, usage),56extractAndStoreMemories(c.env, sessionId, text),57]),58)59},6061onAbort: ({ steps }) => {62c.executionCtx.waitUntil(63saveAssistantMessage(c.env.DB, sessionId, {64text: steps.map((s) => s.text).join(''),65status: 'aborted',66}),67)68},69})7071return result.toUIMessageStreamResponse({72// 往 UIMessage.metadata 注入业务数据73messageMetadata: ({ part }) => {74if (part.type === 'finish') {75return {76sessionId,77intimacy: profile.intimacy,78}79}80},81})82})8384export default chatRoute
4.3 tools
api/src/tools/search-memory.ts
01import { tool } from 'ai'02import { z } from 'zod'0304export function searchMemoryTool(env: Env, sessionId: string) {05return tool({06description: '从长期记忆库检索相关回忆,每条包含内容、相似度和标签。',07inputSchema: z.object({08query: z.string().describe('检索关键词'),09topK: z.number().int().min(1).max(10).default(3),10}),11execute: async ({ query, topK }, { abortSignal }) => {12const embedding = await env.AI.run('@cf/baai/bge-m3', { text: [query] })13const { matches } = await env.VECTORIZE.query(embedding.data[0], {14topK,15filter: { sessionId },16returnMetadata: true,17})18return matches.map((m) => ({19id: m.id,20content: m.metadata?.content,21relevance: m.score,22tags: m.metadata?.tags ?? [],23}))24},25})26}
api/src/tools/update-emotion.ts
01import { tool } from 'ai'02import { EmotionSchema } from '@shared/schemas/companion'0304export function updateEmotionTool(env: Env, sessionId: string) {05return tool({06description: '记录用户当前情绪到数据库。',07inputSchema: EmotionSchema,08execute: async ({ primary, intensity }) => {09await env.DB.prepare(10'INSERT INTO emotion_logs (session_id, emotion, intensity, created_at) VALUES (?,?,?,?)',11).bind(sessionId, primary, intensity, Date.now()).run()12return { success: true, emotion: primary }13},14})15}
api/src/tools/index.ts
1export function buildCompanionTools(env: Env, sessionId: string) {2return {3searchMemory: searchMemoryTool(env, sessionId),4updateEmotion: updateEmotionTool(env, sessionId),5}6}
4.4 带中间件的 model
api/src/models.ts
01import { wrapLanguageModel } from 'ai'02import { createWorkersAI } from 'workers-ai-provider'03import { createOpenAI } from '@ai-sdk/openai'04import { rateLimitMiddleware } from './middleware/rate-limit'05import { fallbackMiddleware } from './middleware/fallback'0607export function buildCompanionModel(env: Env, sessionId: string, userId: string) {08const workersai = createWorkersAI({ binding: env.AI })09const primary = workersai('@cf/meta/llama-3.3-70b-instruct-fp8-fast')1011const openai = createOpenAI({ apiKey: env.OPENAI_API_KEY })12const fallback = openai('gpt-4o-mini')1314return wrapLanguageModel({15model: primary,16middleware: [17rateLimitMiddleware({18kv: env.KV,19keyFn: () => userId,20limit: 120,21windowSec: 60,22}),23fallbackMiddleware(fallback),24],25})26}
5. 前端:Next.js + useChat
5.1 页面
web/src/app/chat/page.tsx
1import { ChatClient } from '@/components/chat'23export default function ChatPage() {4return (5<main className="mx-auto max-w-180 py-8">6<ChatClient />7</main>8)9}
5.2 Chat 组件
web/src/components/chat.tsx
01'use client'02import { useChat } from '@ai-sdk/react'03import { DefaultChatTransport } from 'ai'04import { useState } from 'react'05import { Message } from './message'06import type { CompanionDataParts } from '@shared/schemas/data-parts'07import type { UIMessage } from 'ai'0809type CompanionMessage = UIMessage<{ sessionId: string; intimacy: number }, CompanionDataParts>1011export function ChatClient() {12const [input, setInput] = useState('')13const sessionId = useSessionId()1415const { messages, sendMessage, status, stop, regenerate, error } =16useChat<CompanionMessage>({17transport: new DefaultChatTransport({18api: '/api/chat',19prepareSendMessagesRequest: ({ messages, id }) => ({20body: { sessionId, messages },21}),22}),23})2425return (26<div className="flex flex-col gap-4">27<ul className="flex flex-col gap-3">28{messages.map((m) => <Message key={m.id} message={m} />)}29</ul>3031{status === 'streaming' && (32<button onClick={stop} className="text-xs text-gray-500">33停止生成34</button>35)}3637{error && (38<div className="text-red-500">39出错:{error.message}40<button onClick={() => regenerate()} className="ml-2 underline">41重试42</button>43</div>44)}4546<form47onSubmit={(e) => {48e.preventDefault()49if (!input.trim()) return50sendMessage({ text: input })51setInput('')52}}53className="flex gap-2"54>55<input56value={input}57onChange={(e) => setInput(e.target.value)}58placeholder="说点什么..."59className="flex-1 rounded-md border px-3 py-2"60disabled={status !== 'ready'}61/>62<button63type="submit"64disabled={status !== 'ready'}65className="rounded-md bg-black px-4 py-2 text-white disabled:opacity-50"66>67发送68</button>69</form>70</div>71)72}
5.3 Message 组件(UI Parts 分发)
web/src/components/message.tsx
01import type { UIMessage } from 'ai'02import { TextPart } from './text-part'03import { ReasoningPart } from './reasoning-part'04import { MemoryCard } from './tool-parts/memory-card'05import { EmotionBadge } from './data-parts/emotion-badge'0607export function Message({ message }: { message: UIMessage }) {08return (09<li className={`message message--${message.role}`}>10<div className="role-label">{message.role === 'user' ? '你' : '小舟'}</div>11<div className="body">12{message.parts.map((p, i) => {13if (p.type === 'text') return <TextPart key={i} part={p} />14if (p.type === 'reasoning') return <ReasoningPart key={i} part={p} />15if (p.type === 'data-emotion')16return <EmotionBadge key={i} data={p.data} />17if (p.type === 'tool-searchMemory' && p.state === 'output-available')18return <MemoryCard key={i} memories={p.output} />19return null20})}21</div>22</li>23)24}
其他 part 组件按之前的模式实现,不一一展开。