第 01 篇我们画出了四层架构,诊断出 ByteBot 模型层的四大痛点:无重试、无缓存、超时一刀切、成本不可见。本篇就针对这四点,基于 api/chat.js 的真实代码逐项修复。
先明确一个底层认知:LLM API 调用层,本质上和任何分布式系统的上游调用没有区别。你在调用一个远程 HTTP 服务,它可能超时、限流、返回 5xx、偶发错误。只是以前我们调数据库、调第三方 REST API 时养成的那套”重试 + 超时 + 熔断 + 缓存”习惯,在调 LLM 时被”AI 很高级”的错觉掩盖了。其实 LLM 调用的工程化方法论,和 20 年前的 SOA 架构殊途同归。
一、现状代码的问题在哪
api/chat.js 的调用核心只有一段:
1 | const response = await fetch('https://api.deepseek.com/v1/chat/completions', { |
四个致命问题:
- 零重试:
fetch抛异常直接进 catch。DeepSeek 生产环境偶发的 5xx、网络抖动、Vercel 冷启动(每次冷启动第一次调用延迟高达 1~3 秒)都会导致失败。 - 超时一刀切:30 秒对所有请求生效。一次要推荐 8 篇文章的长回答,流式中间超时被掐断,用户看到半截回答。
- 429 无退避:触发限流后立刻返回错误,没有
Retry-After尊重、没有退避重试。 - 无缓存无成本模型:每次请求都是全价,相同问题重复付费,且没有任何计量。
二、重试:指数退避 + 抖动
2.1 原则
重试不是简单重发,必须遵守三条原则:
- 只对幂等或可安全重发的请求重试:LLM 补全本身无副作用,重发完全安全,但流式响应中重试需要谨慎——如果客户端已经收到部分流,重发会造成内容重复。所以重试窗口应放在”尚未开始吐字”的阶段。
- 指数退避:第 n 次重试前等待
base * 2^n毫秒,并加上随机抖动(jitter)防止惊群。 - 尊重
Retry-After:429 响应头里服务端明确告诉你等多久,此时应直接用它的值。
2.2 实现
1 | function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } |
对 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 | class RetryBudget { |
这样既保证了重试次数,又保证了”最多等 60 秒”的硬上限——对流式请求尤其重要,因为用户等不了太久。
四、限流与 429 处理
ByteBot 前端 classifyError() 已经识别了 429(”服务忙,请稍后再试 🐟”),但那是”通知用户失败”级别的处理,不是工程级的。
生产级的 429 策略分三档:
- 上游 429 退避重试:见 2.2 节,尊重
Retry-After。 - 客户端本地限速:ByteBot 的
debounceInterval: 1000只是防抖,真正的限速应放到后端——记录每个 IP 每 10 秒的请求数,超限直接 429,防止一个刷子脚本打爆你的 API 账单。 - 熔断(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 | 正常路径: POST /api/chat → DeepSeek → 流式回答 |
降级设计的三层境界:
- 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 | system prompt(含 8 篇文章候选):约 800~1500 token ← 大头! |
注意两个反直觉的成本点:
- system prompt 比用户问题贵得多。ByteBot 的 system prompt 每轮都重新注入 8 篇文章的标题+摘要+系列(最多 8 × 180 字摘要),这部分是纯输入,占单次成本的 60% 以上。第 03 篇的”上下文压缩”就是针对它的。
- 历史消息重复计费。6 轮历史每轮都重发,越聊越贵。这就是为什么上下文压缩/滚动摘要能省真金白银。
7.2 计量埋点
成本不可见就无法优化。api/chat.js 现在完全不记录用量,改造第一步是在响应里把 usage 结构透出来:
1 | { |
然后写入日志或 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 篇文章全文注入,升级成滚动摘要 + 记忆分层 + 结构化输出——这是省钱和质量双赢的一篇。
本系列持续更新中,每周一篇。