单轮问答接口

要点

  • 单轮问答是 AI 应用最简单的接口形态:前端发一段文本,后端调一次大模型,把回答返回
  • 请求参数里有几个关键变量——prompt、model、temperature、max_tokens——每个都会影响回答质量和成本
  • 调用 OpenAI 和 Anthropic 的 API 格式不同,但思路一样:构造 messages 数组,POST 到对应端点
  • 响应格式需要统一封装,前端不应该去适配不同模型方返回的 JSON 结构差异
  • 错误处理要区分「模型方错误」和「系统级错误」,两种情况的恢复策略不同
  • 流式响应(SSE)能显著降低用户感知延迟,后端只需把大模型的流式输出透传给前端

1. 什么是单轮问答接口

所谓「单轮」,就是一次请求里只包含一轮交互:用户发一段文本(prompt),后端调一次大模型,拿到回答后返回。没有对话历史,没有上下文记忆,每次请求独立。

适合这种接口的场景:

  • 翻译工具:输入一段中文,返回英文
  • 文本摘要:丢一篇文章进来,拿回摘要
  • 格式转换:把自然语言转成 JSON
  • 代码片段生成:描述需求,拿到一段代码
  • 内容审核:输入一段文本,判断是否违规

共同点:不需要知道「上一轮说了什么」,每次请求自给自足。

2. 请求参数设计

前端发过来的请求体通常长这样:

// request body
{
  "prompt": "用一句话解释什么是量子计算",
  "model": "gpt-4o-mini",
  "temperature": 0.7,
  "max_tokens": 512
}

逐个拆解。

prompt:用户输入

很多场景还需要一段 system prompt(系统提示),告诉大模型「你是谁,该怎么做」。实际发给大模型的 messages 数组通常长这样:

// messages 构造
const messages = [
  { role: 'system', content: '你是一个中英翻译器,只输出翻译结果,不要解释。' },
  { role: 'user', content: userPrompt },
]

system prompt 可以写死在代码里,也可以做成可配置的。

model:选择哪个模型

把 model 暴露给前端,让调用方决定用哪个模型,是一种常见做法。但更稳妥的方式是在后端维护一个白名单:

// src/config/models.ts
export const ALLOWED_MODELS = [
  'gpt-4o-mini', 'gpt-4o',
  'claude-haiku-4-5', 'claude-sonnet-4-20250514',
] as const

这样做有两个好处:防止调用方传不存在的模型 ID;下线某个模型时只改后端配置。

temperature:回答的随机程度

temperature 控制输出的确定性,取值 0-2:

  • 0:每次输出几乎一样,适合代码生成、数据提取
  • 0.7:有一定随机性,适合写作、头脑风暴(也是常见默认值)
  • 1.0 以上:更发散,适合创意场景,但也更容易跑偏

翻译、摘要这类任务设低一些(0-0.3),结果更稳定。

max_tokens:回答的最大长度

限制大模型一次回答最多输出多少个 token(一个汉字约 1-2 个 token,英文单词约 1 个 token)。不设上限的话,复杂 prompt 可能触发几千 token 的输出,直接拉高成本。

参考值:翻译一句话 100-200,写摘要 500-1000,生成代码 1000-2000,开放问答 2048。

把这些参数整合到 Hono 路由里:

// src/routes/qa.ts
import { Hono } from 'hono'
import { z } from 'zod'
import { zValidator } from '@hono/zod-validator'
import type { AppEnv } from '../types'
import { ALLOWED_MODELS } from '../config/models'
 
const qaApp = new Hono<AppEnv>()
 
const qaRequestSchema = z.object({
  prompt: z.string().min(1).max(10000),
  model: z.enum(ALLOWED_MODELS).default('gpt-4o-mini'),
  temperature: z.number().min(0).max(2).default(0.7),
  max_tokens: z.number().int().min(1).max(8192).default(2048),
})
 
qaApp.post('/', zValidator('json', qaRequestSchema), async (c) => {
  const { prompt, model, temperature, max_tokens } = c.req.valid('json')
  // 调用大模型 API ...
  return c.json({ reply: '...' })
})
 
export default qaApp

zod 校验会在业务逻辑之前拦住不合法输入:prompt 为空、model 不在白名单、temperature 越界,zValidator 自动返回 400。

3. 调用大模型 API

参数校验通过后,拿着这些参数去调大模型。OpenAI 和 Anthropic 的 API 格式不同,但核心思路相同:POST messages 数组,解析返回的 JSON。

调用 OpenAI API

// src/services/openai.ts
export async function callOpenAI(params: {
  apiKey: string; model: string
  messages: { role: string; content: string }[]
  temperature: number; max_tokens: number
}) {
  const response = await fetch('https://api.openai.com/v1/chat/completions', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${params.apiKey}`,
    },
    body: JSON.stringify({
      model: params.model, messages: params.messages,
      temperature: params.temperature, max_tokens: params.max_tokens,
    }),
  })
  if (!response.ok) {
    const error = await response.json()
    throw new Error(`OpenAI API error: ${response.status} - ${JSON.stringify(error)}`)
  }
  const data = await response.json()
  return data.choices[0].message.content
}

在路由里调用:

// src/routes/qa.ts
qaApp.post('/', zValidator('json', qaRequestSchema), async (c) => {
  const { prompt, model, temperature, max_tokens } = c.req.valid('json')
  const reply = await callOpenAI({
    apiKey: c.env.OPENAI_API_KEY, model,
    messages: [
      { role: 'system', content: '你是一个有帮助的助手。' },
      { role: 'user', content: prompt },
    ],
    temperature, max_tokens,
  })
  return c.json({ reply })
})

调用 Anthropic API

Anthropic 有几个区别:

  1. 认证用 x-api-key header,不是 Authorization: Bearer
  2. 必须传 anthropic-version header
  3. system prompt 是独立的 system 字段,不在 messages 数组里
  4. 回答在 content[0].text
// src/services/anthropic.ts
export async function callAnthropic(params: {
  apiKey: string; model: string; system: string
  messages: { role: string; content: string }[]
  temperature: number; max_tokens: number
}) {
  const response = await fetch('https://api.anthropic.com/v1/messages', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-api-key': params.apiKey,
      'anthropic-version': '2023-06-01',
    },
    body: JSON.stringify({
      model: params.model, system: params.system,
      messages: params.messages,
      temperature: params.temperature, max_tokens: params.max_tokens,
    }),
  })
  if (!response.ok) {
    const error = await response.json()
    throw new Error(`Anthropic API error: ${response.status}`)
  }
  const data = await response.json()
  return data.content[0].text
}

两家的调用结构几乎一样。如果项目需要同时支持多家模型,可以把差异封装到一个统一接口后面,按 model 前缀分发:

// src/services/llm.ts
export async function callLLM(params: {
  env: Record<string, string>; model: string; system: string
  prompt: string; temperature: number; max_tokens: number
}): Promise<string> {
  const messages = [{ role: 'user', content: params.prompt }]
  if (params.model.startsWith('gpt-') || params.model.startsWith('o')) {
    return callOpenAI({
      apiKey: params.env.OPENAI_API_KEY, model: params.model,
      messages: [{ role: 'system', content: params.system }, ...messages],
      temperature: params.temperature, max_tokens: params.max_tokens,
    })
  }
  if (params.model.startsWith('claude-')) {
    return callAnthropic({
      apiKey: params.env.ANTHROPIC_API_KEY, model: params.model,
      system: params.system, messages,
      temperature: params.temperature, max_tokens: params.max_tokens,
    })
  }
  throw new Error(`Unsupported model: ${params.model}`)
}

路由代码因此变得干净:

// src/routes/qa.ts
qaApp.post('/', zValidator('json', qaRequestSchema), async (c) => {
  const { prompt, model, temperature, max_tokens } = c.req.valid('json')
  const reply = await callLLM({
    env: c.env, model, system: '你是一个有帮助的助手。',
    prompt, temperature, max_tokens,
  })
  return c.json({ reply })
})

4. 响应格式设计

直接返回 &#123; reply &#125; 能跑,但信息量太少。一个实用的响应格式还应该包含模型名和 token 用量:

// src/types/response.ts
type QAResponse = {
  reply: string
  model: string
  usage: {
    prompt_tokens: number
    completion_tokens: number
    total_tokens: number
  }
}

OpenAI 的响应自带 usage 字段,直接取。Anthropic 的字段名不同(input_tokens / output_tokens),需要做映射。在各自的 service 函数里统一返回值格式:

// src/services/openai.ts
return {
  reply: data.choices[0].message.content,
  usage: {
    prompt_tokens: data.usage.prompt_tokens,
    completion_tokens: data.usage.completion_tokens,
    total_tokens: data.usage.total_tokens,
  },
}
 
// src/services/anthropic.ts
return {
  reply: data.content[0].text,
  usage: {
    prompt_tokens: data.usage.input_tokens,
    completion_tokens: data.usage.output_tokens,
    total_tokens: data.usage.input_tokens + data.usage.output_tokens,
  },
}

前端拿到的响应:

// response
{
  "reply": "量子计算是一种利用量子力学原理进行信息处理的计算方式。",
  "model": "gpt-4o-mini",
  "usage": { "prompt_tokens": 25, "completion_tokens": 48, "total_tokens": 73 }
}

前端可以展示 token 消耗,后端可以拿 usage 做成本统计。

5. 错误处理

大模型 API 调用至少要区分两类错误:

  1. 模型方错误:API Key 无效、模型过载、输入超长、频率超限
  2. 系统级错误:网络超时、DNS 解析失败、Worker 执行时间超限

模型方错误有明确的错误码,可以翻译成用户能理解的提示;系统级错误通常只能告诉用户「稍后重试」。

模型方错误

常见状态码和处理方式:

状态码含义处理方式
401API Key 无效后端配置问题,返回 500,不暴露给前端
429频率超限提示用户稍后重试
400参数有误翻译错误信息返回给前端
500/503模型方故障提示用户稍后重试

封装一个自定义异常类:

// src/lib/errors.ts
export class LLMError extends Error {
  constructor(
    public readonly statusCode: number,
    public readonly type: string,
    message: string,
  ) {
    super(message)
    this.name = 'LLMError'
  }
}

在 service 层抛出,在路由层捕获并分类处理:

// src/routes/qa.ts
try {
  const result = await callLLM({ env: c.env, model, system: '...', prompt, temperature, max_tokens })
  return c.json({ reply: result.reply, model, usage: result.usage })
} catch (error) {
  if (error instanceof LLMError) {
    if (error.statusCode === 401) return c.json({ error: '服务配置异常' }, 500)
    if (error.statusCode === 429) return c.json({ error: '请求过于频繁' }, 429)
    return c.json({ error: error.message }, error.statusCode)
  }
  console.error('Unexpected error:', error)
  return c.json({ error: '服务暂时不可用,请稍后重试' }, 500)
}

超时控制

大模型 API 调用通常需要 1-10 秒。应该给 fetch 加超时,防止模型方响应过慢导致 Worker 卡住:

// src/services/openai.ts
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 30000)
try {
  const response = await fetch(url, {
    // ...其他配置
    signal: controller.signal,
  })
  // ...
} finally {
  clearTimeout(timeout)
}

6. 流式响应基础

上面的接口等模型全部生成完再一次性返回。大模型生成一段文字可能需要 3-10 秒,用户看着空白页面等 5 秒然后文字突然出现——体验很差。

流式响应的思路是:模型每生成几个字就立刻推给前端,效果像打字机,感知延迟从「等 5 秒」变成「0.5 秒后开始出字」。

请求时开启 stream

在请求体里加 stream: true,响应的 body 就变成 ReadableStream,数据块长这样:

data: {"choices":[{"delta":{"content":"量子"}}]}
data: {"choices":[{"delta":{"content":"计算"}}]}
data: [DONE]

用 Hono 的 streamSSE 透传

Hono 提供了 streamSSE 来写 SSE 流。后端把大模型返回的流读出来,逐块写回去:

// src/routes/qa.ts
import { streamSSE } from 'hono/streaming'
 
qaApp.post('/stream', zValidator('json', qaRequestSchema), async (c) => {
  const { prompt, model, temperature, max_tokens } = c.req.valid('json')
  const stream = await callOpenAIStream({
    apiKey: c.env.OPENAI_API_KEY, model,
    messages: [
      { role: 'system', content: '你是一个有帮助的助手。' },
      { role: 'user', content: prompt },
    ],
    temperature, max_tokens,
  })
 
  return streamSSE(c, async (sseStream) => {
    const reader = stream.getReader()
    const decoder = new TextDecoder()
    while (true) {
      const { done, value } = await reader.read()
      if (done) break
      const text = decoder.decode(value, { stream: true })
      const lines = text.split('\n').filter(l => l.startsWith('data: '))
      for (const line of lines) {
        const data = line.slice(6)
        if (data === '[DONE]') {
          await sseStream.writeSSE({ data: '[DONE]', event: 'done' })
          return
        }
        await sseStream.writeSSE({ data, event: 'message' })
      }
    }
  })
})

前端用 EventSource 或 fetch + ReadableStream 消费 SSE 流,逐块拼接文本即可。

注意:流式模式下大模型不返回 usage 字段。token 统计可以用 tiktoken 本地计算,或交给 AI Gateway Dashboard 自动记录。

总结

把这一篇的内容串起来,一个完整的单轮问答接口包含这些部分:

  • 请求参数校验:用 zod 约束 prompt、model、temperature、max_tokens 的合法范围
  • 统一调用层:把 OpenAI、Anthropic 等不同模型方的 API 差异封装到 callLLM() 后面
  • 响应格式统一:返回 reply + model + usage,前端不需要关心底层用的是哪家模型
  • 错误处理:区分模型方错误(401/429/400/500)和系统级错误,给前端返回可理解的提示
  • 流式响应:加 stream: true 开启流式,用 streamSSE 透传,降低用户感知延迟

下一篇会在这个基础上加入多轮对话——需要管理对话历史和上下文窗口,复杂度会上一个台阶。