ByteFisher AI 工程化深度实战(二):LLM 调用层工程化

第 01 篇我们画出了四层架构,诊断出 ByteBot 模型层的四大痛点:无重试、无缓存、超时一刀切、成本不可见。本篇就针对这四点,基于 api/chat.js 的真实代码逐项修复。

先明确一个底层认知:LLM API 调用层,本质上和任何分布式系统的上游调用没有区别。你在调用一个远程 HTTP 服务,它可能超时、限流、返回 5xx、偶发错误。只是以前我们调数据库、调第三方 REST API 时养成的那套”重试 + 超时 + 熔断 + 缓存”习惯,在调 LLM 时被”AI 很高级”的错觉掩盖了。其实 LLM 调用的工程化方法论,和 20 年前的 SOA 架构殊途同归。

一、现状代码的问题在哪

api/chat.js 的调用核心只有一段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const response = await fetch('https://api.deepseek.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${apiKey}`
},
signal: AbortSignal.timeout(DEEPSEEK_TIMEOUT), // 固定 30 秒
body: JSON.stringify({
model: 'deepseek-chat',
messages: outboundMessages,
temperature: 0.7,
max_tokens: 2000,
stream: !!stream
})
});

四个致命问题:

  1. 零重试fetch 抛异常直接进 catch。DeepSeek 生产环境偶发的 5xx、网络抖动、Vercel 冷启动(每次冷启动第一次调用延迟高达 1~3 秒)都会导致失败。
  2. 超时一刀切:30 秒对所有请求生效。一次要推荐 8 篇文章的长回答,流式中间超时被掐断,用户看到半截回答。
  3. 429 无退避:触发限流后立刻返回错误,没有 Retry-After 尊重、没有退避重试。
  4. 无缓存无成本模型:每次请求都是全价,相同问题重复付费,且没有任何计量。

二、重试:指数退避 + 抖动

2.1 原则

重试不是简单重发,必须遵守三条原则:

  • 只对幂等或可安全重发的请求重试:LLM 补全本身无副作用,重发完全安全,但流式响应中重试需要谨慎——如果客户端已经收到部分流,重发会造成内容重复。所以重试窗口应放在”尚未开始吐字”的阶段。
  • 指数退避:第 n 次重试前等待 base * 2^n 毫秒,并加上随机抖动(jitter)防止惊群。
  • 尊重 Retry-After:429 响应头里服务端明确告诉你等多久,此时应直接用它的值。

2.2 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); }

async function fetchWithRetry(url, options, {
maxRetries = 2,
baseDelay = 500,
maxDelay = 8000,
retryableStatuses = [429, 500, 502, 503, 504]
} = {}) {
let lastErr = null;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
const response = await fetch(url, options);
if (!retryableStatuses.includes(response.status)) return response;

// 尊重服务端的 Retry-After
const retryAfter = Number(response.headers.get('retry-after'));
const delay = retryAfter
? retryAfter * 1000
: Math.min(maxDelay, baseDelay * Math.pow(2, attempt) + Math.random() * 300);
if (attempt < maxRetries) await sleep(delay);
else return response;
} catch (err) {
lastErr = err;
// 网络级错误(ECONNRESET 等)也值得退避重试
if (attempt >= maxRetries) throw err;
await sleep(Math.min(maxDelay, baseDelay * Math.pow(2, attempt) + Math.random() * 300));
}
}
throw lastErr;
}

对 ByteBot 的取舍:serverless 函数不宜重试太多次——Vercel 免费层有执行时长限制,重试 2 次(每次最长 8 秒退避)已经是上限。重试策略必须和超时联动,见下一节。

三、超时:分层设置,别一刀切

3.1 问题的本质

ByteBot 把 AbortSignal.timeout(30000) 挂在 fetch 上,这是”连接 + 首字节 + 全部响应体”的总超时。对一次 max_tokens=2000 的流式响应,30 秒常常不够;而对一个马上要报 400 的坏请求,30 秒又太长。

正确的做法是分层超时

超时 依据
连接建立 5 秒 DNS + TCP + TLS,正常情况远小于此
首字节(TTFB) 15 秒 服务端排队 + 首 token 生成
流式空闲 15 秒 相邻两个 chunk 的最大间隔(现有 STREAM_IDLE_TIMEOUT 保留)
总时长 60 秒 兜底,防挂死

3.2 与重试的联动

重试的语义应该是”在没开始吐字前的失败才重试“。实现方式:给 AbortController 一个统一的 total 预算(比如 60 秒),重试消耗这个预算,预算耗尽即整体失败。

1
2
3
4
5
6
class RetryBudget {
constructor(ms) { this.remaining = ms; this.t0 = Date.now(); }
spent() { return Date.now() - this.t0; }
left() { return this.remaining - this.spent(); }
exhausted() { return this.left() <= 0; }
}

这样既保证了重试次数,又保证了”最多等 60 秒”的硬上限——对流式请求尤其重要,因为用户等不了太久。

四、限流与 429 处理

ByteBot 前端 classifyError() 已经识别了 429(”服务忙,请稍后再试 🐟”),但那是”通知用户失败”级别的处理,不是工程级的。

生产级的 429 策略分三档:

  1. 上游 429 退避重试:见 2.2 节,尊重 Retry-After
  2. 客户端本地限速:ByteBot 的 debounceInterval: 1000 只是防抖,真正的限速应放到后端——记录每个 IP 每 10 秒的请求数,超限直接 429,防止一个刷子脚本打爆你的 API 账单。
  3. 熔断(Circuit Breaker):连续失败超过阈值(比如 10 秒内失败 5 次)就进入打开状态,直接短路拒绝后续请求 30 秒,给上游恢复时间。serverless 无状态,熔断状态要放 Redis 或 TiKV,而不是内存。

对 ByteBot 来说,第 2 档在 Vercel 里实现最简单——因为 serverless 无状态,用 upstash-ratelimit(Redis)最省事。第 3 档熔断对博客这种低频场景收益有限,但思路必须知道,第 10 篇做多 Agent 时会用到。

五、降级:能优雅失败,就是降级

ByteBot 已经做了一个很聪明的降级:API 失败时,前端 showFallbackRecommendations() 用本地 BM25 索引推荐相关文章ai-assistant.js:439)。这意味着即便 DeepSeek 挂了,读者依然能得到有价值的站内导航。

1
2
3
4
正常路径:  POST /api/chat → DeepSeek → 流式回答
降级路径1: API 失败 → 本地 BM25 推荐文章列表
降级路径2: API 失败且非博客问题 → 提示稍后重试
降级路径3(第06篇):API 失败 → 本地模型兜底

降级设计的三层境界:

  • L1 可用:核心功能(查博客、导航)不因 AI 不可用而瘫痪 → ByteBot 已达到。
  • L2 体面:降级时提示用户”AI 服务暂时不可用,先看这些文章”,不暴露技术错误 → 已达到。
  • L3 主动:降级路径也有数据(哪些问题触发了降级),反哺到评测集 → 第 05/11 篇实现。

六、缓存:LLM 缓存的两个层次

6.1 第一层:精确命中缓存

完全相同的问题,返回缓存的完整回答。对 ByteBot 高频问题(”有哪些 Unity 教程”、”钓鱼地图在哪”)收益巨大。实现上用一个 Map<questionHash, answer> + TTL,放在 Redis/Upstash,键可以是问题的规范化哈希。

关键点:规范化"有哪些 Unity 教程?""有哪些Unity教程" 是同一个问题。可以用第 01 篇提到的 norm() 思路做归一化(去空格、全半角统一)后再取哈希。

6.2 第二层:语义缓存

精确命中太严格。更进一步:把问题的 embedding 存下来,新问题与缓存问题的余弦相似度超过阈值(比如 0.95)就命中。这层缓存正确性有风险(语义相近但答案不同),适合低成本场景,不宜作为默认策略。字节跳动、阿里云的 LLM 网关都有此能力,但自建成本高。

对 ByteBot 的落地建议:只做第一层精确缓存 + 规范化,收益已足够。同时必须注意——带历史消息的对话不适合缓存(上下文不同),缓存只应作用于”首轮、单条 user 消息”的场景。这一点也和第 03 篇的会话管理挂钩。

6.3 缓存的副作用

缓存最怕”过期不更新”。ByteBot 推荐的文章列表跟着博客内容变,缓存 TTL 不能太长(建议 6~12 小时),且检索型回答最好不缓存——因为文章在更新,检索结果在变。缓存只用于纯闲聊或静态知识问答。

七、成本建模:让每一分钱可见

7.1 成本公式

LLM 调用成本 = 输入 Token × 输入单价 + 输出 Token × 输出单价。

DeepSeek-chat 定价(参考公开价格)约 输入 ¥1/M token、输出 ¥2/M token(缓存命中 ¥0.1/M)。一次 ByteBot 典型问答的 Token 构成:

1
2
3
4
5
6
system prompt(含 8 篇文章候选):约 800~1500 token   ← 大头!
用户问题:约 50~200 token
历史对话(6 轮):约 500~2000 token
输出回答:约 300~800 token
─────────────────────────────────────
单次成本:约 0.002~0.004 元

注意两个反直觉的成本点

  1. system prompt 比用户问题贵得多。ByteBot 的 system prompt 每轮都重新注入 8 篇文章的标题+摘要+系列(最多 8 × 180 字摘要),这部分是纯输入,占单次成本的 60% 以上。第 03 篇的”上下文压缩”就是针对它的。
  2. 历史消息重复计费。6 轮历史每轮都重发,越聊越贵。这就是为什么上下文压缩/滚动摘要能省真金白银。

7.2 计量埋点

成本不可见就无法优化。api/chat.js 现在完全不记录用量,改造第一步是在响应里把 usage 结构透出来:

1
2
3
4
5
6
7
{
"usage": {
"prompt_tokens": 1234,
"completion_tokens": 456,
"total_tokens": 1690
}
}

然后写入日志或 TiDB 一张 ai_usage 表:时间、模型、prompt_tokens、completion_tokens、成本、是否命中缓存、是否重试过。第 11 篇做 Langfuse 时,这些字段就是 trace 的基础。

7.3 模型路由:按场景定价

不同任务用不同模型,是成本优化最立竿见影的手段。ByteBot 的场景矩阵:

场景 建议模型 原因
博客导览(检索+推荐) deepseek-chat 知识面够,便宜
闲聊 deepseek-chat 低延迟优先
钓鱼日记生成(离线流水线) deepseek-chat 或 deepseek-reasoner 离线可慢,允许思考模型
本地兜底(第06篇) Qwen2.5-7B 量化 免费但慢,仅降级用

路由逻辑很简单:intent === 'blog' 用快模型,离线任务用强模型,服务降级用本地模型。路由本身也是一个可观测点(第 11 篇埋点),要能统计”路由到哪个模型、为什么”。

八、落地清单与小结

针对 ByteBot api/chat.js 的改造优先级排序:

优先级 改造 收益 成本
P0 指数退避重试(2 次) 立减 5xx 失败率
P0 分层超时(连接 5s / TTFB 15s / 流式空闲 15s / 总 60s) 长回答不再被掐断
P1 usage 埋点落库 成本可见,开启后续优化
P1 精确缓存(规范化 + TTL) 高频问题省 30% 成本
P2 限流(Upstash/Redis) 防刷防超支
P3 模型路由 场景化成本最优

核心结论:

  • LLM 调用层就是普通的上游 HTTP 调用,重试、超时、限流、缓存、成本建模这套方法论照搬即可
  • serverless 下的特殊约束:重试次数要少、熔断状态要外置、超时预算要收紧。
  • 成本大头是 system prompt 和历史消息的重复计费,省 Token 的顺序是:压缩上下文 > 缓存 > 模型路由
  • 降级不是失败,而是工程成熟度的体现;ByteBot 的 BM25 兜底设计值得保留并强化。

下一篇是上下文工程:把 ByteBot 的 6 轮原始历史 + 8 篇文章全文注入,升级成滚动摘要 + 记忆分层 + 结构化输出——这是省钱和质量双赢的一篇。


本系列持续更新中,每周一篇。

ByteFisher
分享编程技术 · 记录钓鱼乐趣
扫码关注
▸ 扫码关注 ◂
分享: