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 chat2.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$post 的 json 参数类型由后端的 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<typeof weatherToolSchema> 推导参数类型,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. 大响应类型的性能考量
类型安全本身不会带来运行时开销(类型信息编译后擦除),但有几个相关实践值得注意:
- 流式响应避免一次性构造大对象。
streamSSE天然逐 chunk 发送,前端类型定义也不会因此膨胀。 - RAG 搜索结果按需求裁剪字段。前端只需要标题和分数时,不必传完整
content和metadata。可以在后端定义「精简版」响应类型,和完整版共享底层 schema。 - 避免重复校验。Zod schema 的
.parse()是运行时操作,对特别大的响应体(完整 conversation history),可以在入口处校验一次,内部传递时用z.infer<>推导的纯类型。
延伸阅读
- 03-Hono Client 使用方式 —
hc客户端的基本用法和类型推导机制 - 06-流式响应与 SSE — Hono 流式处理的底层实现
- 11-AI 基础 — AI 后端开发的基础概念
- 14-RAG — RAG 管线的完整搭建
- 15-Agent 与工具调用 — Tool calling 的深度实践
总结
RPC 类型共享在 AI 项目中的价值,可以归纳为三个层面:
- 结构层面:AI 接口嵌套深、字段多、变体复杂(消息列表、流式事件、工具参数、检索结果),共享类型能从根上消除前后端的结构漂移
- 流式层面:SSE 事件的
event和data之间存在类型依赖关系,共享类型让前端在逐块消费时也能拿到正确的类型收窄 - 错误层面:AI API 的特有错误场景(速率限制、token 超限、模型不可用、内容过滤),组织成类型化联合结构,前端错误处理从「猜」变成「匹配」
下一篇讲 RPC 场景中常见的类型错误——类型推导失败、链式写法中断、app.route 类型丢失,以及对应的排查方法。