RPC在AI项目中的应用

要点

  • AI 项目的请求和响应结构比 CRUD 复杂得多:嵌套消息列表、流式分块、工具调用参数,类型断裂带来的风险也更高
  • Hono RPC 的类型推导从 Zod schema 一路传导到前端调用侧,AI 接口的每一层嵌套都能拿到类型保护
  • 流式响应(SSE)是 AI 接口的标配,streamSSE 结合类型化事件让前端逐块消费时仍有类型安全
  • Tool calling 的参数和返回值适合用 Zod schema 定义,后端校验和前端类型推导共用同一份定义
  • RAG 场景涉及 embedding、检索结果、上下文注入等多层数据,RPC 类型共享能避免前后端对结构各写各的
  • AI API 特有的错误类型(速率限制、token 超限、模型不可用),做成类型化联合结构比 if (status === 429) 更可靠
  • 这篇是 RPC 系列与 AI 后端专题(11-15 章)之间的桥梁,后面的流式处理、RAG、Agent 都依赖这里建立的类型基础

1. AI 项目为什么更需要类型安全

前面几篇的例子都是用户 CRUD——结构扁平,字段有限。AI 项目的数据结构复杂度通常高出一个量级:

// types/chat.ts
interface ChatCompletionRequest {
  model: string
  messages: ChatMessage[]
  temperature?: number
  max_tokens?: number
  stream?: boolean
  tools?: ToolDefinition[]
  tool_choice?: 'auto' | 'none' | { type: 'function'; function: { name: string } }
}
 
type ContentPart =
  | { type: 'text'; text: string }
  | { type: 'image_url'; image_url: { url: string; detail?: 'low' | 'high' | 'auto' } }

这种嵌套结构在前端要用来:构造请求体、渲染对话历史、处理工具调用中间态、展示流式输出。如果前后端各自维护类型,任何一个字段名对不上(tool_calls 写成 toolCalls),页面不会崩溃,但功能会悄悄失效。

嵌套越深,类型断裂的后果越隐蔽。 user.name 的错误一眼能看出来;message.tool_calls[0].function.arguments 的错误可能要触发特定工具才会暴露。这是 AI 项目更适合从第一天就用 RPC 类型共享的原因。

2. 聊天完成接口的类型定义

2.1 后端路由

// server/routes/chat.ts
import { Hono } from 'hono'
import { zValidator } from '@hono/zod-validator'
import { z } from 'zod'
 
const chatRequestSchema = z.object({
  model: z.string().min(1),
  messages: z.array(z.object({
    role: z.enum(['system', 'user', 'assistant', 'tool']),
    content: z.string(),
  })).min(1),
  temperature: z.number().min(0).max(2).optional(),
  max_tokens: z.number().positive().optional(),
  stream: z.boolean().optional(),
})
 
const chat = new Hono()
  .post('/completions', zValidator('json', chatRequestSchema), async (c) => {
    const body = c.req.valid('json')
    const result = await callLLMProvider(body)
    return c.json({
      id: result.id,
      choices: [{ index: 0, message: { role: 'assistant' as const, content: result.content }, finish_reason: result.finishReason }],
      usage: { prompt_tokens: result.promptTokens, completion_tokens: result.completionTokens, total_tokens: result.totalTokens },
    })
  })
 
export default chat

2.2 前端 RPC 调用

// client/chat.ts
import { hc } from 'hono/client'
import type { AppType } from '../server/app'
 
const client = hc<AppType>('http://localhost:8787')
 
const res = await client.api.chat.completions.$post({
  json: {
    model: 'gpt-4o',
    messages: [{ role: 'user', content: '你好' }],
    temperature: 0.7,
    // TypeScript 检查:messages 至少一项,role 必须是联合类型中的值
  },
})
const data = await res.json()
// data.choices[0].message.content → string
// data.usage.total_tokens → number
// 全部自动推导,不需要手写 interface

$postjson 参数类型由后端的 chatRequestSchema 推导。前端传了 schema 里没有的字段,或 temperature 写了 3(超过 max(2)),TypeScript 编译期就会报错。

3. 流式响应:SSE 与类型化事件

AI 接口最常见的交互模式是流式输出。Hono 的 streamSSE 配合类型化事件定义,能让前端消费 SSE 事件时保持类型安全。

3.1 类型化事件定义

// shared/types/chat-stream.ts
export type ChatStreamEvent =
  | { event: 'message'; data: { choices: Array<{ delta: { content: string; role: 'assistant' }; finish_reason: string | null }> } }
  | { event: 'done'; data: { usage: { prompt_tokens: number; completion_tokens: number } } }
  | { event: 'error'; data: { error: { code: string; message: string } } }

3.2 后端流式路由

// server/routes/chat.ts
import { streamSSE } from 'hono/streaming'
 
const chatStream = new Hono()
  .post('/completions/stream', zValidator('json', chatRequestSchema), async (c) => {
    const body = c.req.valid('json')
    return streamSSE(c, async (stream) => {
      for await (const chunk of callLLMStream(body)) {
        await stream.writeSSE({
          event: 'message',
          data: { choices: [{ delta: { content: chunk.content, role: 'assistant' }, finish_reason: chunk.finishReason }] },
        })
      }
      await stream.writeSSE({ event: 'done', data: { usage: { prompt_tokens: pTokens, completion_tokens: cTokens } } })
    })
  })

3.3 前端消费

// client/chat-stream.ts
for (const event of events) {
  // event 类型是 ChatStreamEvent,TypeScript 按 event 字段收窄 data 结构
  switch (event.event) {
    case 'message':
      // event.data.choices[0].delta.content 是 string
      appendToUI(event.data.choices[0].delta.content)
      break
    case 'done':
      // event.data.usage 是 { prompt_tokens: number; completion_tokens: number }
      updateTokenCount(event.data.usage)
      break
    case 'error':
      showError(event.data.error.message)
      break
  }
}

流式场景下类型共享的价值:SSE 的 data 结构随 event 类型变化,共享类型之后,event 字段的联合类型会约束 data 的结构,TypeScript 的类型收窄能提前捕获不匹配。

4. Tool Calling 的类型化参数与返回值

Tool calling 是 LLM 与应用逻辑之间的桥梁:模型决定调用哪个工具、传什么参数,后端执行并喂回结果。

4.1 一份 schema,三处使用

// shared/tools/weather.ts
import { z } from 'zod'
 
export const weatherToolSchema = z.object({
  location: z.string(),
  unit: z.enum(['celsius', 'fahrenheit']).default('celsius'),
})
 
export interface WeatherToolResult {
  temperature: number
  unit: 'celsius' | 'fahrenheit'
  condition: string
}

Zod schema 用于后端校验 LLM 传来的参数,z.infer&lt;typeof weatherToolSchema&gt; 推导参数类型,WeatherToolResult 约束返回值结构。如果还需要给 LLM 发 JSON Schema 格式的 tools 描述,可以写一个 zodToJsonSchema 转换函数,从同一份 Zod schema 生成,避免维护两套定义。

4.2 后端执行

// server/routes/tools.ts
const tools = new Hono()
  .post('/execute', zValidator('json', z.object({
    tool_name: z.string(),
    arguments: z.record(z.unknown()),
  })), async (c) => {
    const { tool_name, arguments: args } = c.req.valid('json')
    if (tool_name === 'get_weather') {
      const parsed = weatherToolSchema.safeParse(args)
      if (!parsed.success) return c.json({ error: 'invalid_tool_arguments' }, 400)
      // parsed.data 类型已经是 { location: string; unit: 'celsius' | 'fahrenheit' }
      const result: WeatherToolResult = await fetchWeather(parsed.data)
      return c.json(result)
    }
    return c.json({ error: 'unknown_tool' }, 404)
  })

5. RAG 请求与响应的类型结构

RAG 管线涉及多层数据:embedding → 向量检索 → 上下文构建 → LLM 调用。类型共享能贯穿整条链路。

// shared/types/rag.ts
export interface SearchResult {
  id: string
  content: string
  score: number
  metadata: { source: string; title: string; chunk_index: number }
}
 
// 比普通 chat 响应多了引用信息
export interface RAGChatResponse {
  answer: string
  sources: Array<{
    result: SearchResult
    relevance: 'high' | 'medium' | 'low'
    quoted_text: string
  }>
  model: string
  usage: { prompt_tokens: number; completion_tokens: number }
}

后端路由用 zValidator 校验请求,返回值标注为 RAGChatResponse。前端拿到之后,sources 数组里每一项都有完整类型:result.score 是 number、relevance 是三个枚举值之一、quoted_text 是 string。渲染引用列表时不需要 as 断言。

6. 多模型路由与类型化参数

AI 后端通常对接多个 LLM 提供商,不同模型参数有差异。用联合类型把模型特有参数约束表达出来:

// shared/types/models.ts
interface BaseChatParams {
  messages: ChatMessage[]
  temperature?: number
  max_tokens?: number
}
 
interface OpenAIParams extends BaseChatParams {
  model: 'gpt-4o' | 'gpt-4o-mini' | 'o1'
  response_format?: { type: 'json_object' | 'text' }
  tools?: ToolDefinition[]
}
 
interface AnthropicParams extends BaseChatParams {
  model: 'claude-sonnet-4-20250514' | 'claude-3-5-sonnet-20241022'
  top_p?: number
  top_k?: number
}
 
type ChatParams = OpenAIParams | AnthropicParams

后端路由按 model 字段分发到不同提供商客户端。如果后续新增一个提供商,只需在联合类型里加一个分支,TypeScript 会在所有 switch 分支中检查是否遗漏了新类型。这种保护在模型频繁迭代的 AI 项目里很有价值。

7. AI API 特有的错误类型

AI 接口有一组不同于常规 CRUD 的错误场景,适合做成类型化的联合结构:

// shared/types/ai-errors.ts
export type AIError =
  | { type: 'rate_limit'; retry_after: number; limit_type: 'requests_per_minute' | 'tokens_per_day'; message: string }
  | { type: 'token_limit'; max_tokens: number; requested_tokens: number; message: string }
  | { type: 'model_unavailable'; model: string; fallback_model?: string; message: string }
  | { type: 'content_filter'; filter_category: string; message: string }
  | { type: 'provider_error'; provider: string; status_code: number; message: string }

前端处理时,error.type 作为类型收窄依据:

// client/error-handler.ts
function handleAIError(error: AIError) {
  switch (error.type) {
    case 'rate_limit':
      // error.retry_after 是 number
      showToast(`请在 ${error.retry_after} 秒后重试`)
      break
    case 'token_limit':
      showToast(`输入超出限制:${error.requested_tokens}/${error.max_tokens} tokens`)
      break
    case 'model_unavailable':
      if (error.fallback_model) showToast(`已切换到 ${error.fallback_model}`)
      break
    // ...
  }
}

如果 AIError 联合里加了一种新错误而 handleAIError 没处理,TypeScript 的 exhaustive check 会提醒。

8. 完整示例:类型安全的 AI 聊天 API

把前面几节串起来:

// server/app.ts — 链式挂载,保留路由类型
const route = app
  .use('/*', cors())
  .route('/api/chat', chat)
  .route('/api/rag', rag)
  .route('/api/tools', tools)
 
export type AppType = typeof route
// client/app.ts
const client = hc<AppType>('http://localhost:8787')
 
export const chatAPI = {
  complete: async (messages: Array<{ role: string; content: string }>) => {
    const res = await client.api.chat.completions.$post({ json: { model: 'gpt-4o', messages } })
    return res.json()
  },
  askWithContext: async (query: string) => {
    const res = await client.api.rag.chat.$post({ json: { query, top_k: 5 } })
    return res.json() // 返回类型自动推导为 RAGChatResponse
  },
}

这份客户端代码里没有任何 as 断言,类型从后端路由定义一路推导到前端调用侧。

9. 大响应类型的性能考量

类型安全本身不会带来运行时开销(类型信息编译后擦除),但有几个相关实践值得注意:

  1. 流式响应避免一次性构造大对象streamSSE 天然逐 chunk 发送,前端类型定义也不会因此膨胀。
  2. RAG 搜索结果按需求裁剪字段。前端只需要标题和分数时,不必传完整 contentmetadata。可以在后端定义「精简版」响应类型,和完整版共享底层 schema。
  3. 避免重复校验。Zod schema 的 .parse() 是运行时操作,对特别大的响应体(完整 conversation history),可以在入口处校验一次,内部传递时用 z.infer<> 推导的纯类型。

延伸阅读

总结

RPC 类型共享在 AI 项目中的价值,可以归纳为三个层面:

  1. 结构层面:AI 接口嵌套深、字段多、变体复杂(消息列表、流式事件、工具参数、检索结果),共享类型能从根上消除前后端的结构漂移
  2. 流式层面:SSE 事件的 eventdata 之间存在类型依赖关系,共享类型让前端在逐块消费时也能拿到正确的类型收窄
  3. 错误层面:AI API 的特有错误场景(速率限制、token 超限、模型不可用、内容过滤),组织成类型化联合结构,前端错误处理从「猜」变成「匹配」

下一篇讲 RPC 场景中常见的类型错误——类型推导失败、链式写法中断、app.route 类型丢失,以及对应的排查方法。