AI 电子伴侣
创建时间: 2026-04-18最后更新: 2026-08-04

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
01
import { z } from 'zod'
02
03
export const ChatRequestSchema = z.object({
04
sessionId: z.string().min(1),
05
messages: z.array(z.object({
06
id: z.string(),
07
role: z.enum(['user', 'assistant', 'system']),
08
parts: z.array(z.unknown()),
09
})).min(1),
10
})
11
12
export type ChatRequest = z.infer<typeof ChatRequestSchema>
shared/schemas/companion.ts
01
import { z } from 'zod'
02
03
export const EmotionSchema = z.object({
04
primary: z.enum(['happy', 'sad', 'angry', 'calm', 'neutral']),
05
intensity: z.number().min(0).max(1),
06
})
07
export type Emotion = z.infer<typeof EmotionSchema>
08
09
export const MemorySchema = z.object({
10
id: z.string(),
11
content: z.string(),
12
relevance: z.number(),
13
tags: z.array(z.string()),
14
})
15
export type Memory = z.infer<typeof MemorySchema>

data part 的泛型类型:

shared/schemas/data-parts.ts
1
import type { Emotion } from './companion'
2
3
export type CompanionDataParts = {
4
emotion: Emotion
5
'memories-used': { count: number }
6
}

这些 schema 前后端都 import。

4. 后端:Hono + AI SDK 主接口

4.1 入口

api/src/index.ts
01
import { Hono } from 'hono'
02
import { cors } from 'hono/cors'
03
import chatRoute from './routes/chat'
04
import type { AppBindings } from './bindings'
05
06
const app = new Hono<AppBindings>()
07
08
app.use('*', cors({ origin: ['https://companion.yourdomain.com'] }))
09
app.route('/api', chatRoute)
10
11
export default app

4.2 /chat 路由

api/src/routes/chat.ts
01
import { Hono } from 'hono'
02
import { zValidator } from '@hono/zod-validator'
03
import { streamText, convertToModelMessages } from 'ai'
04
import { ChatRequestSchema } from '@shared/schemas/chat'
05
import { buildCompanionModel } from '../models'
06
import { buildCompanionTools } from '../tools'
07
import { buildSystemPrompt } from '@shared/prompts/companion'
08
import type { AppBindings } from '../bindings'
09
10
const chatRoute = new Hono<AppBindings>()
11
12
chatRoute.post('/chat', zValidator('json', ChatRequestSchema), async (c) => {
13
const { sessionId, messages } = c.req.valid('json')
14
15
// 1. 加载上下文
16
const profile = await loadUserProfile(c.env.DB, sessionId)
17
const memories = await searchMemories(c.env, sessionId, messages)
18
19
// 2. 构造 system prompt
20
const system = buildSystemPrompt({
21
userNickname: profile.nickname,
22
personalityTags: profile.tags,
23
intimacy: profile.intimacy,
24
recentEmotions: profile.recentEmotions,
25
memories,
26
currentTime: new Date().toISOString(),
27
})
28
29
// 3. 构造带中间件的 model
30
const model = buildCompanionModel(c.env, sessionId, profile.userId)
31
32
// 4. streamText 驱动
33
const result = streamText({
34
model,
35
system,
36
messages: convertToModelMessages(messages as any),
37
tools: buildCompanionTools(c.env, sessionId),
38
stopWhen: stepCountIs(5),
39
abortSignal: c.req.raw.signal,
40
41
experimental_telemetry: {
42
isEnabled: true,
43
functionId: 'companion-chat',
44
metadata: {
45
sessionId,
46
userId: profile.userId,
47
'prompt.version': 'companion@v1.3',
48
},
49
},
50
51
onFinish: ({ text, usage }) => {
52
c.executionCtx.waitUntil(
53
Promise.all([
54
saveAssistantMessage(c.env.DB, sessionId, text),
55
updateIntimacy(c.env.DB, sessionId, usage),
56
extractAndStoreMemories(c.env, sessionId, text),
57
]),
58
)
59
},
60
61
onAbort: ({ steps }) => {
62
c.executionCtx.waitUntil(
63
saveAssistantMessage(c.env.DB, sessionId, {
64
text: steps.map((s) => s.text).join(''),
65
status: 'aborted',
66
}),
67
)
68
},
69
})
70
71
return result.toUIMessageStreamResponse({
72
// 往 UIMessage.metadata 注入业务数据
73
messageMetadata: ({ part }) => {
74
if (part.type === 'finish') {
75
return {
76
sessionId,
77
intimacy: profile.intimacy,
78
}
79
}
80
},
81
})
82
})
83
84
export default chatRoute

4.3 tools

api/src/tools/search-memory.ts
01
import { tool } from 'ai'
02
import { z } from 'zod'
03
04
export function searchMemoryTool(env: Env, sessionId: string) {
05
return tool({
06
description: '从长期记忆库检索相关回忆,每条包含内容、相似度和标签。',
07
inputSchema: z.object({
08
query: z.string().describe('检索关键词'),
09
topK: z.number().int().min(1).max(10).default(3),
10
}),
11
execute: async ({ query, topK }, { abortSignal }) => {
12
const embedding = await env.AI.run('@cf/baai/bge-m3', { text: [query] })
13
const { matches } = await env.VECTORIZE.query(embedding.data[0], {
14
topK,
15
filter: { sessionId },
16
returnMetadata: true,
17
})
18
return matches.map((m) => ({
19
id: m.id,
20
content: m.metadata?.content,
21
relevance: m.score,
22
tags: m.metadata?.tags ?? [],
23
}))
24
},
25
})
26
}
api/src/tools/update-emotion.ts
01
import { tool } from 'ai'
02
import { EmotionSchema } from '@shared/schemas/companion'
03
04
export function updateEmotionTool(env: Env, sessionId: string) {
05
return tool({
06
description: '记录用户当前情绪到数据库。',
07
inputSchema: EmotionSchema,
08
execute: async ({ primary, intensity }) => {
09
await env.DB.prepare(
10
'INSERT INTO emotion_logs (session_id, emotion, intensity, created_at) VALUES (?,?,?,?)',
11
).bind(sessionId, primary, intensity, Date.now()).run()
12
return { success: true, emotion: primary }
13
},
14
})
15
}
api/src/tools/index.ts
1
export function buildCompanionTools(env: Env, sessionId: string) {
2
return {
3
searchMemory: searchMemoryTool(env, sessionId),
4
updateEmotion: updateEmotionTool(env, sessionId),
5
}
6
}

4.4 带中间件的 model

api/src/models.ts
01
import { wrapLanguageModel } from 'ai'
02
import { createWorkersAI } from 'workers-ai-provider'
03
import { createOpenAI } from '@ai-sdk/openai'
04
import { rateLimitMiddleware } from './middleware/rate-limit'
05
import { fallbackMiddleware } from './middleware/fallback'
06
07
export function buildCompanionModel(env: Env, sessionId: string, userId: string) {
08
const workersai = createWorkersAI({ binding: env.AI })
09
const primary = workersai('@cf/meta/llama-3.3-70b-instruct-fp8-fast')
10
11
const openai = createOpenAI({ apiKey: env.OPENAI_API_KEY })
12
const fallback = openai('gpt-4o-mini')
13
14
return wrapLanguageModel({
15
model: primary,
16
middleware: [
17
rateLimitMiddleware({
18
kv: env.KV,
19
keyFn: () => userId,
20
limit: 120,
21
windowSec: 60,
22
}),
23
fallbackMiddleware(fallback),
24
],
25
})
26
}

5. 前端:Next.js + useChat

5.1 页面

web/src/app/chat/page.tsx
1
import { ChatClient } from '@/components/chat'
2
3
export default function ChatPage() {
4
return (
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'
02
import { useChat } from '@ai-sdk/react'
03
import { DefaultChatTransport } from 'ai'
04
import { useState } from 'react'
05
import { Message } from './message'
06
import type { CompanionDataParts } from '@shared/schemas/data-parts'
07
import type { UIMessage } from 'ai'
08
09
type CompanionMessage = UIMessage<{ sessionId: string; intimacy: number }, CompanionDataParts>
10
11
export function ChatClient() {
12
const [input, setInput] = useState('')
13
const sessionId = useSessionId()
14
15
const { messages, sendMessage, status, stop, regenerate, error } =
16
useChat<CompanionMessage>({
17
transport: new DefaultChatTransport({
18
api: '/api/chat',
19
prepareSendMessagesRequest: ({ messages, id }) => ({
20
body: { sessionId, messages },
21
}),
22
}),
23
})
24
25
return (
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>
30
31
{status === 'streaming' && (
32
<button onClick={stop} className="text-xs text-gray-500">
33
停止生成
34
</button>
35
)}
36
37
{error && (
38
<div className="text-red-500">
39
出错:{error.message}
40
<button onClick={() => regenerate()} className="ml-2 underline">
41
重试
42
</button>
43
</div>
44
)}
45
46
<form
47
onSubmit={(e) => {
48
e.preventDefault()
49
if (!input.trim()) return
50
sendMessage({ text: input })
51
setInput('')
52
}}
53
className="flex gap-2"
54
>
55
<input
56
value={input}
57
onChange={(e) => setInput(e.target.value)}
58
placeholder="说点什么..."
59
className="flex-1 rounded-md border px-3 py-2"
60
disabled={status !== 'ready'}
61
/>
62
<button
63
type="submit"
64
disabled={status !== 'ready'}
65
className="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
01
import type { UIMessage } from 'ai'
02
import { TextPart } from './text-part'
03
import { ReasoningPart } from './reasoning-part'
04
import { MemoryCard } from './tool-parts/memory-card'
05
import { EmotionBadge } from './data-parts/emotion-badge'
06
07
export function Message({ message }: { message: UIMessage }) {
08
return (
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) => {
13
if (p.type === 'text') return <TextPart key={i} part={p} />
14
if (p.type === 'reasoning') return <ReasoningPart key={i} part={p} />
15
if (p.type === 'data-emotion')
16
return <EmotionBadge key={i} data={p.data} />
17
if (p.type === 'tool-searchMemory' && p.state === 'output-available')
18
return <MemoryCard key={i} memories={p.output} />
19
return null
20
})}
21
</div>
22
</li>
23
)
24
}

其他 part 组件按之前的模式实现,不一一展开。