单轮问答接口
要点
- 单轮问答是 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 qaAppzod 校验会在业务逻辑之前拦住不合法输入: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 有几个区别:
- 认证用
x-api-keyheader,不是Authorization: Bearer - 必须传
anthropic-versionheader - system prompt 是独立的
system字段,不在 messages 数组里 - 回答在
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. 响应格式设计
直接返回 { reply } 能跑,但信息量太少。一个实用的响应格式还应该包含模型名和 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 调用至少要区分两类错误:
- 模型方错误:API Key 无效、模型过载、输入超长、频率超限
- 系统级错误:网络超时、DNS 解析失败、Worker 执行时间超限
模型方错误有明确的错误码,可以翻译成用户能理解的提示;系统级错误通常只能告诉用户「稍后重试」。
模型方错误
常见状态码和处理方式:
| 状态码 | 含义 | 处理方式 |
|---|---|---|
| 401 | API 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透传,降低用户感知延迟
下一篇会在这个基础上加入多轮对话——需要管理对话历史和上下文窗口,复杂度会上一个台阶。