Skip to content

重构端到端 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 写,和前几章完全一致:

typescript
// 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>
typescript
// 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 的泛型类型:

typescript
// 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 入口

typescript
// 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 app

4.2 /chat 路由

typescript
// 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': 'companion@v1.3',

      },

    },

    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 chatRoute

4.3 tools

typescript
// 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 ?? [],

      }))

    },

  })

}
typescript
// 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 }

    },

  })

}
typescript
// api/src/tools/index.ts
export function buildCompanionTools(env: Env, sessionId: string) {

  return {

    searchMemory: searchMemoryTool(env, sessionId),

    updateEmotion: updateEmotionTool(env, sessionId),

  }

}

4.4 带中间件的 model

typescript
// 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 页面

tsx
// 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 组件

tsx
// 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 分发)

tsx
// 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 组件按之前的模式实现,不一一展开。

基于 MIT 协议开源