从 prototype 到 production · 工程基底
7 节 · vibe coding ≠ production + 7 个 production-quality 维度 + Sync/Async/Stateless/Stateful + DB/Cache/Queue + HTTP status/Retry/Idempotency + Latency/Throughput/Cost trade-off lens。
这一章是 Stage 3 工程基底的核心,约 2 小时,全 Mode A,无 Mode B 视频。我们把 LLM 系统从我电脑跑通推到线上服务可用。读完后,你看一个真实 LLM 产品的架构图,知道每个框框是什么,以及为什么选这个不选那个。
这一章没有 Mode B 视频的原因是 SWE production 主题分散,语言、状态、错误处理、trade-off 不在同一个视频里,也没有单一合格视频 cover 整套体系。所以我们 Mode A 必须扛起,用 narrative 把 production engineering 的几个核心 mental model 串成一段可读的导览。
5 大块内容:vibe vs production 的本质差距,7 个 production-quality 维度,sync/async/stateless/stateful 的状态与时间维度,DB/Cache/Queue 三件套基础设施,失败处理与 trade-off lens 决策框架。这一章后接 Ch5(部署 + 监控 + OS 视角)是 Stage 3 的延续。学完这两章你具备把 LLM prototype 推到 production 的工程基底。
一段 vibe-coded LLM call 长什么样,production 又长什么样?
让我用具体代码开篇。这是绝大多数 vibe coder 写的 LLM call:
def chat(msg):
try:
r = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=1000,
messages=[{"role": "user", "content": msg}]
)
return r.content[0].text
except:
return "Error"5 行代码,能跑,一个简单的 chatbot demo,跑通它给你看 AI 在我电脑上 work 的瞬间满足感。
现在让我们看同一个功能的 production 版:
import structlog
from anthropic import Anthropic, RateLimitError, APIStatusError
from app.models import Response
log = structlog.get_logger()
client = Anthropic() # 用环境变量 ANTHROPIC_API_KEY
class ChatError(Exception):
"""Raised when chat call fails after retries."""
def chat(
message: str,
system: str | None = None,
max_tokens: int = 1024,
max_retries: int = 3,
) -> Response:
"""
Send a single-turn chat message and return the response.
Retries on transient errors (5xx, rate limit) with exponential backoff.
Raises ChatError after max_retries.
"""
messages = [{"role": "user", "content": message}]
delay = 1.0
for attempt in range(max_retries):
try:
response = client.messages.create(
model="claude-sonnet-4-6",
max_tokens=max_tokens,
system=system,
messages=messages,
)
log.info("chat_success",
input_tokens=response.usage.input_tokens,
output_tokens=response.usage.output_tokens,
attempt=attempt)
return Response(text=response.content[0].text, usage=response.usage)
except RateLimitError as e:
log.warning("rate_limited", attempt=attempt)
time.sleep(e.retry_after or delay)
delay *= 2
except APIStatusError as e:
if e.status_code >= 500:
log.warning("server_error", code=e.status_code, attempt=attempt)
time.sleep(delay)
delay *= 2
else:
log.error("client_error", code=e.status_code, error=str(e))
raise ChatError(f"Client error: {e}")
raise ChatError(f"Max retries ({max_retries}) exceeded")40 行代码。两段代码做的事情从用户视角看一模一样,你输入一句话,它返回 AI 的回复。但这 40 行 vs 5 行的差距,正是 demo 和 production 之间的全部距离。每多出来的一行都不是装饰,每一行都在处理 production 现实里会发生的事情:API 会限流(RateLimitError),服务器会临时挂掉(APIStatusError 5xx),网络会超时,token 用量需要追踪,错误需要分类(retryable vs not),出错时需要日志才能 debug,调用方需要明确的错误类型(ChatError),参数需要可配置(不能硬编码 1000)。
这一章我们 unpack 这个 35 行差距的全部内容。Production engineering 不是更多代码,而是更多预设 reality 的代码。这种预设 reality 的思维,是 vibe coder 到 production engineer 转型最大的认知改变。这层为什么突然对 vibe coder 重要,也值得放到 Ch 1 时间线上看:2022 年 ChatGPT 之前,把 LLM 推上 production 还是少数公司的内部问题;2024-2025 agent 时代,每个想把 demo 变成真产品的人都撞上这 35 行的差距,这正是 4 大实验室的 JD 把 production-quality 编程 verbatim 列为入场 baseline 的原因。后面 5 节我们一节一节走,从代码本身的 7 个 quality 维度开始,推到状态、数据基础设施、失败处理,最后到 trade-off 判断框架。
1. 7 个 production-quality 维度
vibe-coded 到 production-quality 的转型,可以拆成 7 个具体维度。我们一个一个走,每个维度给你为什么这件事重要,vibe vs production 的具体差距,加上 production 经验。
1.1 命名 · 让代码自解释
Production code 的变量、函数、类名,应该让一个陌生人不看 docs 也能猜对它的作用。让我用具体例子拉开差距。vibe 风格:
def f(d, n=5):
r = []
for i in d[:n]:
r.append(i["t"])
return r读完你只能猜大概是个 filter 函数,从某个 list 取前 n 个元素的某个字段。但你不知道 d 是什么 list,t 是什么字段,这个函数的业务意图是什么。Production 风格则把意图全部写在签名上:
def extract_top_titles(documents: list[dict], n: int = 5) -> list[str]:
"""Return the title field of the first n documents."""
return [doc["title"] for doc in documents[:n]]差别不只是字符多。Production 版能直接搜代码,grep extract_top_titles 就知道哪里调过它;能直接 type check,list[dict] 加 list[str] 让 IDE 标红错误调用;不需要文档就懂。更重要的是,命名是一个团队 communication tool。在团队规模超过 1 个人时,好命名比写注释更重要,因为命名直接出现在所有调用点,注释只在定义点。一年后你回到这段代码,你不会去翻 docstring,你会直接读函数名。函数名告诉你它在干什么你就能继续读下去;函数名没意义,你每次都得 jump to definition 看一遍。
1.2 错误处理 · typed errors + 显式 policy
回到开篇的 5 行 vs 40 行例子。最大的差距集中在错误处理上。vibe 写法常见这种:
try:
result = call_llm(prompt)
except:
return None这种代码的问题不是它没处理错误,是它把所有错误都当成一回事。Rate limit 错误、网络超时、API key 配置错、context 超长、模型不存在、5xx 服务器挂掉、4xx 参数错全部走同一个 except 分支,全部返回 None。后果是当用户报告 AI 不工作了,你完全不知道在哪一层。是 API key 失效了?是 rate limit 了?是 prompt 太长了?是模型 endpoint 改了?你无法 debug,因为错误信息被吃掉了。
Production 写法分类处理:
from anthropic import RateLimitError, APIStatusError, APIConnectionError
def call_with_retry(prompt: str, max_retries: int = 3) -> Response:
delay = 1.0
for attempt in range(max_retries):
try:
return client.messages.create(...)
except RateLimitError as e:
log.warning("rate_limited", attempt=attempt, retry_after=e.retry_after)
time.sleep(e.retry_after or delay)
delay *= 2
except APIConnectionError as e:
log.warning("connection_error", attempt=attempt, error=str(e))
time.sleep(delay)
delay *= 2
except APIStatusError as e:
if e.status_code >= 500:
log.warning("server_error", attempt=attempt, code=e.status_code)
time.sleep(delay)
delay *= 2
else:
log.error("client_error", code=e.status_code, error=str(e))
raise # 4xx 不 retry
raise MaxRetriesExceeded()差别在四件事:分类错误(rate limit / connection / server / client),每类显式 policy(retry 还是 raise),log 充分(后续 debug 知道发生了什么),不吞错(没分类的错往上抛,不要 silently return None)。这种分类加显式 policy 的错误处理思维,是 production engineer 跟 vibe coder 最大的区别。Vibe coder 想的是 happy path,production engineer 想的是 all paths,每种可能发生的事情都有一个明确的代码响应。1.5 节我们会回到 retry 的更细节策略(backoff / jitter / idempotency)。这里你先建立错误处理是 production 第一公民的思维。
1.3 类型 · type hints + 工具验证
# Vibe
def parse(s):
return s.split(",")
# Production
def parse_tags(s: str) -> list[str]:
"""Parse comma-separated tags, stripped of whitespace."""
return [tag.strip() for tag in s.split(",") if tag.strip()]s: str 和 -> list[str] 这两个类型注解看起来是额外的装饰。但它们在 production 里做的事远超装饰。类型注解一次性解决四件事。第一,IDE autocomplete 准确。当下游代码调 parse_tags(...),IDE 知道返回的是 list[str],可以自动补全 list 上的所有方法。没类型,IDE 不知道,autocomplete 失效。第二,重构安全。如果你以后想把 parse_tags 改成返回 set[str],带类型的代码会让所有 caller 立刻 type error。没类型,你得 grep 加手动查,容易漏,一次重构留下 bug。第三,code review 时不用问这参数啥类型。Reviewer 直接看签名就知道,不用挖实现。Production 项目里这件事每天发生几十次,加起来节省的时间巨大。第四件事是 LLM 写代码更准,这是一个 vibe coder 容易忽视但极重要的点。当你用 Cursor 或 Copilot 或 Claude Code 让 LLM 帮你写下游代码,LLM 会根据 type hint 推断该写什么。Type hint 准确则 LLM 写得准确;type hint 缺失,LLM 靠猜,猜错率上升。工具配套上,Python 用 mypy 或 pyright(VS Code 内置 Pylance);TypeScript 用 tsc;Rust 与 C++ 编译器原生检查。Type hints 不是装饰,是 production 必备。
1.4 测试 · unit + integration 在 CI 里跑
vibe code 经常没 test,或者只有我跑一遍 main() 这种粗糙验证。Production 要求结构化测试:
# tests/test_extract_tags.py
import pytest
from app.parse import parse_tags
def test_basic():
assert parse_tags("a, b, c") == ["a", "b", "c"]
def test_extra_whitespace():
assert parse_tags(" a , b ,c ") == ["a", "b", "c"]
def test_empty_pieces_filtered():
assert parse_tags("a,,b") == ["a", "b"]
def test_empty_string():
assert parse_tags("") == []这种结构化 test 的价值不是找当前 bug,而是防未来回归。你下次改 parse_tags,test 自动跑一遍;如果你的改动破坏了边界 case(比如忘记过滤空字符串),test 立刻红,你立刻知道。配套机制(归 Ch5 部署章)是 CI 每次 PR 自动跑 test,失败 block merge。这样一年下来,test suite 越来越大,每次改动安全度越来越高。LLM 相关的 test 是另一个故事,因为 LLM 输出不确定,unit test 那种精确匹配预期输出的写法不适用。我们 Ch6 会专门讲 LLM eval 的 4 层 framework。本节的 unit test 主要针对 deterministic code 部分(parsing / data manipulation / API client logic),那些跟 LLM 输出的不确定性无关。
1.5 文档 · docstring + README + ADR
不要写讲废话的 docstring,例如 def add(a, b): """Add a and b.""",这种 docstring 不告诉读者任何新信息。要写讲不显然的事情,包括函数为什么存在(不是它在做什么,好命名已经说了)、参数的特殊约束或取值范围、副作用或错误条件、边界 case 行为。举例:
def call_with_retry(prompt: str, max_retries: int = 3) -> Response:
"""
Call LLM with exponential backoff on retryable errors.
Retries on RateLimitError, APIConnectionError, and 5xx APIStatusError.
4xx errors are not retried (raised immediately).
Note: this function blocks. For async use call_with_retry_async.
"""这段 docstring 告诉调用方哪些错误会被 retry、哪些会被立即 raise、这是 blocking 函数、有 async 版本。每一条都是从函数签名读不出来但使用时必须知道的信息。这才是 docstring 的存在价值,把签名说不清的事情说清。项目级文档分两类。README 是项目根目录的入口文档,告诉新人这是什么、怎么跑、怎么测试、怎么部署;不写 README 的项目等于没文档,等于 6 个月后连自己都看不懂。ADR(Architecture Decision Record)记录重要的设计决策,放 docs/adr/,例如我们为什么选 Anthropic 不选 OpenAI、我们为什么用 Postgres 不用 MongoDB;6 个月后自己想换回来时不用从头讨论,你打开 ADR 看到当年的取舍理由,直接判断现在条件变没变。这种文档习惯在 production 团队是默认 culture。Vibe coder 一个人写代码可以不写,但进了 team 一定要补上。
1.6 抽象 · 不要 over-engineered 也不要 under-engineered
抽象是一个双向陷阱。Over-engineered(过度抽象)是一个 prompt 配 5 个 class 加 abstract factory 加 dependency injection,代码看起来很 enterprise 但没用 1 次就重写。Under-engineered(抽象不够)是同一段 LLM call 代码在 8 个文件里复制粘贴,改一处忘改 7 处,bug 满地。那么抽象的中庸之道长什么样?几条 production 经验:2 次重复不抽象,可能不会再有,先 copy 也行;3 次重复抽象,明显的 pattern 该提炼了;抽象就要起好名,LLMClient 比 AIWrapper 好,后者 6 个月后不知道在干嘛。
最重要的标准是抽象应该让 caller 写的代码更短更清楚,而不是我有了一个 framework。如果你的抽象让 caller 多写 3 行配置才能用,可能是 over-engineered。如果你的抽象让 caller 不需要知道 LLM provider 是谁就能 chat(prompt),那是好抽象,减少了 caller 的认知负担。这种从 caller 视角判断抽象价值的思维,Ousterhout 在 A Philosophy of Software Design 里反复讲。foundations 给你这个 mental model,深度展开他书里讲得最透,我们章末 references 列了。
1.7 Git baseline · atomic commits + clean history
最后一个 production-quality 维度,跟代码本身无关,跟代码演化的痕迹有关,也就是 Git history。vibe coding 经常一晚上一个 commit 写个 wip(work in progress)。这种 commit history 在 1 个人项目可以接受,在 team 项目就是灾难,因为后来人无法 trace history 找原因。Production 要求 atomic commits,每个 commit 是一个逻辑单元的完整变更。例如 Add prompt cache friendly system prompt structure 是好的 atomic commit,而 wip + fix typo + refactor llm.py + add feature X 是坏的混杂 commit。第一个 commit 你可以单独 revert 不破坏其他东西。第二个 commit 你 revert 会同时丢掉 4 件不相关的事,一年后这种 commit 就是 history 的噪音。配套的 meaningful messages 原则要求 commit message 说为什么,不说做了什么。代码本身告诉读者做了什么,commit message 告诉读者为什么这么做。
Conventional commits(可选)是一套常见结构:
feat: add prompt cache to system prompt assembly
fix: handle 504 timeout with fallback to smaller model
refactor: extract retry logic to call_with_retry helper
docs: explain why we chose Anthropic over OpenAI in ADR-005每个 commit 类型(feat / fix / refactor / docs / test / chore)告诉 reviewer 这是什么性质的改动。这是团队 git history 的基础规范。Clean history 的具体操作包括用 git rebase -i 把 wip commits 合并成 atomic commits 再 merge、不要 push 一堆 fix 加 fix again 加 really fix this time 到 main、PR merge 用 squash 把 PR 的 N 个 wip commit squash 成 1 个干净 commit。这些 Git habit 在 vibe coding 个人项目里可有可无,进 team 就是 production hygiene 的基本要求。
7 个维度速查
| 维度 | vibe-coded | production-quality |
|---|---|---|
| 命名 | f, x, data | extract_top_titles, documents, n |
| 错误处理 | except: return None | typed errors + 显式 policy(retry/raise) |
| 类型 | 全靠看上下文 | type hints + mypy/tsc 验证 |
| 测试 | 跑一遍 main 看看 | unit + integration tests in CI |
| 文档 | 没有 | docstrings(为什么)+ README + ADR |
| 抽象 | over- 或 under- engineered | 2 次不抽 / 3 次抽 / caller 视角判断价值 |
| Git | 一晚上一个 wip | atomic commits + meaningful messages + clean history |
2. 状态与时间 · Sync/Async/Stateless/Stateful
LLM 系统的另一个 production reality 不在代码组织层,在代码运行层,也就是状态怎么管理、时间怎么处理。这两个维度合起来,定义了 LLM 系统能扛多大并发、能跑多长任务、能存多少历史。我们一个一个走。
2.1 Sync vs Async · 调用一个函数后,你的代码会等吗?
Sync(同步)指你调用一个函数,你的程序就挂在那里等结果,结果回来之前什么都干不了。Async(异步)指你调用一个函数,立刻拿到一个凭证(Promise / Future / Job ID),程序继续往下走,结果好了之后要么你回头查,要么系统主动通知你。类比上,sync 是排队点餐,你站柜台等做好;async 是取号点餐,给你个号,你坐下,做好了叫号。
在 Agent 系统里,大部分 LLM 调用是 sync 的,streaming 本质也是 sync,只是边收边等。但长任务必须 async。具体例子,一个 research agent 要跑 10 分钟才能完成报告。如果你用 sync 调它,前端 HTTP 请求挂 10 分钟会被网络层 timeout 切断,因为浏览器、nginx、load balancer 通常都有 60s 到 300s 的默认 timeout,你的请求还没等到结果就死了。所以长任务要走 job queue 加轮询、WebSocket、Webhook 这种 async 模式:前端立刻返回任务已收到加 job ID 是 xxx,后端 worker 慢慢跑,跑完通过另一个 channel 通知前端。
把这套思维落到 Agent UX 决策:
| 场景 | 选 Sync 还是 Async |
|---|---|
| ChatBot 一问一答(< 30 秒) | Sync + streaming |
| Cursor 写代码(< 1 分钟) | Sync + streaming |
| Deep Research 一份 30 分钟报告 | Async + job + WebSocket 推进度 |
| 批量处理 10 万条数据 | Async + queue + worker |
| 用户上传 PDF,等系统索引完 | Async + job + 完成时邮件通知 |
你设计 Agent 的 UX,本质就是在选 sync 还是 async。这个选择决定了后面要不要上 queue、要不要 WebSocket、要不要做进度通知,整个系统架构的形态都从这里 derive。
2.2 Stateless vs Stateful · 服务器记不记得你?
Stateless(无状态)指服务器不记得你,每次请求都得带齐所有上下文。回到 Ch1 学的 LLM stateless 性质,LLM API 是 stateless 的,所以多轮对话每次都要把整段历史塞进 prompt。Stateful(有状态)指服务器记得你,建立连接后保留你的状态,WebSocket 连接、数据库 session、登录后的会话都是 stateful。类比上,stateless 像便利店收银员,你说一句结账走人,下次再来要重新说一遍;stateful 像私人医生,记得你的病历、过敏史、上次诊断。关键 trade-off 在于 stateless 容易 scale,因为任何服务器都能接你的请求,它不需要记得你的任何东西;你部署 10 台服务器,LB 随机分配,每台都能处理任何请求。Stateful 难 scale,必须把你的状态搬过来,或把你绑定到特定服务器上。这就是为什么云时代默认推 stateless 设计。为什么这件事在 Agent 系统里关键?因为很多 Agent 系统的它怎么忘了上下文问题,本质是没意识到 LLM 是 stateless 的。状态管理是你的责任,不是模型的。
LLM 是 stateless,但你的 Agent 整体不是,你得把状态放在某个地方。常见选择:
| 状态类型 | 放在哪 | 例子 |
|---|---|---|
| 当前对话 | 内存(每个 session 一份) | 进行中的对话历史 |
| 用户长期偏好 | Database | user 喜欢简洁回答 |
| 工具状态 / 已访问的文件 | Database 或 dedicated state store | agent 已经读过 README.md |
| 临时结果(crash 可丢) | Cache(Redis) | LLM 已经算过的 embedding |
| 长任务进度 | Job queue 自带 | Research agent 现在 step 4/10 |
这个表把状态从一个抽象词分解到具体存储位置。Production engineer 设计 Agent 时,每一类状态都要明确放在哪儿,这是基本功。下一节我们就来看这些存储位置背后的三件套基础设施。
3. DB / Cache / Queue · 三件套基础设施
回到 2.2 节那个表,状态可能放 Database / Cache / Queue 这三种基础设施里。这一节我们 unpack 这三件套各自做什么。LLM Agent 系统 90% 项目都用 Postgres 加 Redis 加 Vector DB 这套组合。理解这三种基础设施的角色分工,是设计 Agent 数据架构的第一步。
3.1 Database · 数据库
Database 干两件事:把数据持久化保存(关机重启数据还在),加上支持查询。为什么不能用 JSON 文件代替?让我用具体场景 unpack。第一,并发写。两个进程同时写同一个 JSON 文件,文件会被搞坏。Database 用 transaction 保证 isolation,同时写不冲突。第二,复杂查询。找出所有 2 周内 active 且 retention 高的用户这种需求,JSON 文件你得自己 load 进内存加 filter 加 sort;database 用 SQL 一行搞定,且 indexed 查询比线性扫描快 100 到 1000 倍。第三,事务,要么全成要么全败。转账场景必须用事务:从 A 账户扣钱加给 B 账户加钱,中间任一步失败两步都得回滚,JSON 文件没法保证。第四,规模。100 万行 JSON 加载即崩(几 GB 文件读到内存),database 索引 100 万行查询毫秒级。第五,崩溃恢复。写一半断电 JSON 损坏;database 有 WAL(write-ahead log)保证 crash 后能恢复。
数据库分两大门派。SQL(Postgres / MySQL / SQLite)用表加列加关系明确,强一致加事务,适合订单、用户、账户这类强结构数据。在 Agent 系统里,用户表、会话表、agent 配置表都是 SQL。NoSQL 灵活 schema 加扩展性强,适合日志、文档、KV、向量这类非强结构数据。NoSQL 是个大箩筐,里面又分几个子类型:
- KV(Redis):key 找 value,飞快
- Document(MongoDB):存 JSON 文档,半结构化
- Vector(Pinecone / Weaviate / Chroma / Qdrant):存 embedding,做 similarity search
- Graph(Neo4j):存关系网络,做图遍历
在 Agent 系统里,90% 项目都用 Postgres(主数据)加 Redis(cache + session)加 Vector DB(RAG 用)三件套。其他 NoSQL 子类型在特殊场景才上。
3.2 Cache · 缓存
Cache 干一件事:把贵的东西(慢查询、LLM 调用)暂存一份,下次省事。类比上 cache 像便利店的热销货架,不用每次都跑去仓库取,但货架上的不一定是最新,可能 stale(过期)。但 cache 也是一把双刃剑,用错地方就是定时炸弹。具体说,缓存有几个经典坑,本章不展开,知道存在就行:
| 坑名 | 是什么 | 后果 |
|---|---|---|
| Cache stampede | 缓存过期瞬间,所有请求同时查 DB | DB 被打挂 |
| Cache stale | 缓存里的数据已经过期但还在用 | 显示错的数据 |
| Cache inconsistency | 主数据更新了 cache 没更新 | 用户看到旧值 |
| Hot key | 一个 key 太热门,单个 cache server 顶不住 | 局部过载 |
Production 的 cache 系统都要处理这些。本章只让你知道有这些坑,真要解决,需要 #2 系统设计深入。
在 Agent 系统里,cache 的典型应用有四种。LLM 调用结果 cache,同样的 prompt 多次调,第二次取 cache;注意要严格命中(prompt 一字不差),不然会 cache 错的结果。Embedding cache,同样文本不重复算 embedding;embedding 比 LLM 便宜但仍要钱,同样的文档 chunk 不应该算两次。Tool result cache,某些 idempotent 的工具(查天气、查股价、读公开网页)结果可以短期 cache。Session cache,活跃会话放 Redis,减少 DB 压力,每次用户请求查上次说到哪了都先看 Redis。
3.3 Queue · 消息队列
回到 2.1 节,长任务必须 async,async 的实现机制就是 queue(消息队列)。Queue 干一件事:异步缓冲,生产者投信,消费者按节奏取。类比上 queue 像邮局信箱,寄信人(生产者)把信扔进去,收信人(消费者)按自己节奏取,两人不必同时在场。Queue 解决两件事。第一是削峰,突发流量先入箱,慢慢处理。100 个用户同时提交任务,如果直接处理后端会爆;先入 queue,worker 按 capacity 慢慢消化。第二是解耦,生产者和消费者节奏不一样,前端立刻返回任务已收到,后端 worker 慢慢跑 30 分钟。在 Agent 系统里,典型场景是长任务(research / report 生成):
用户提交任务
→ API 把任务推入 Queue(立即返回 job_id 给用户)
→ 后台 Worker 从 Queue 取任务
→ Worker 跑 Agent loop(可能 30 分钟)
→ Worker 完成后写结果到 DB + 通知用户(email / webhook / WebSocket)这套架构把用户立刻得到反馈和 Agent 慢慢跑解耦,两边各自按自己节奏。常见 Queue 工具有 Redis BullMQ、RabbitMQ、Kafka、AWS SQS、GCP Pub/Sub。Production 一般是 Kafka 或 Pub/Sub 这类企业级,小项目用 Redis BullMQ 已经够。
4. 失败处理 · HTTP status / Retry / Idempotency
Production engineering 第 4 大块是失败处理。这是 1.2 节错误处理的展开,我们走更深一层。失败处理为什么在 agent 时代尤其要命:Ch 4 讲过 agent 会自主跑几十个 step,一步失败时若 retry 策略写错(retry 了不该 retry 的、操作不 idempotent),错误会沿着循环放大成几千美元账单或重复副作用——单次调用时代不痛不痒的小 bug,在 agent loop 里会被乘上几十倍。90% production Agent 系统的 bug 来自两类问题:失败处理写错了(retry 不该 retry 的、idempotency 没保证)导致数据错、用户被多扣钱、数据库被打挂;trade-off 错位(过度优化 latency 牺牲 cost,或反之)导致月度账单爆炸或用户体感差。我们这一节专攻第一类,trade-off lens 放第 5 节。
4.1 HTTP status code · 失败长什么样
HTTP 用三位数 status code 表达请求结果,前缀含义清楚:2xx 是成功(200 OK / 201 Created);4xx 是你的错(401 没鉴权 / 403 没权限 / 404 找不到 / 429 你太频繁);5xx 是服务器的错(500 内部错 / 502 上游挂了 / 503 服务不可用 / 504 超时)。4xx 是 client 错(你的请求有问题),5xx 是 server 错(服务方挂了)。这个语义直接决定 retry 决策:4xx 通常不该 retry(再发也是错),5xx 可以 retry(服务方修好了你的请求就能成)。
LLM API 常见 status code:
| Code | 含义 | 你应该 |
|---|---|---|
| 200 | 成功 | 用结果 |
| 400 | Bad request(参数错 / context 超长) | 不要 retry,修参数 |
| 401/403 | 鉴权 / 权限错 | 不要 retry,检查 API key |
| 404 | Endpoint / model 不存在 | 不要 retry,检查路径 |
| 429 | Rate limit | 可以 retry,带 backoff 等等再试 |
| 500/502/503 | 服务器错 | 可以 retry,带 backoff |
| 504 | 超时(模型 generate 太慢) | 可以 retry,但考虑下次直接缩短 prompt |
4.2 Retry · 失败了再试,但有学问
Retry 看起来简单,失败了再试一次。但有几条经验合在一起才完整。第一,4xx 通常不要 retry,你重试结果还是错;429 是唯一例外,那是限流,等等再试可能行。第二,5xx 可以 retry,可能是 transient(瞬时)故障。第三,必须搭配 backoff,重试间隔逐渐拉长(1s → 2s → 4s → 8s),否则会雪崩,所有人都同时重试会把刚恢复的服务再次打挂。第四,Jitter(随机扰动),在 backoff 时间上加正负 20% 随机,避免所有 client 同步重试形成共振峰。第五,最大重试次数,通常 3 到 5 次足够,超过说明真的不行,放弃加报错。
Production retry 模板:
import random
import time
def call_with_retry(fn, max_retries=4):
delay = 1.0
for attempt in range(max_retries):
try:
return fn()
except RetryableError as e: # 5xx / 429 / timeout
if attempt == max_retries - 1:
raise
wait = delay * (1 + random.random() * 0.4)
time.sleep(wait)
delay *= 2
except NonRetryableError: # 4xx
raise关键陷阱:很多 vibe coder 写 retry 时一律 except Exception,不分类错误,对所有 Exception 一律 retry。结果 retry 一个 4xx,失败 100 次,把账单刷爆。先分类错误,再决定 retry 策略,这是 retry 设计的第一条纪律。
4.3 Idempotency · 幂等性 · retry 安不安全的根
Retry 看似简单但有一个深坑:你 retry 的操作必须是 idempotent 的,否则 retry 就是 bug。Idempotency(幂等性)指同一操作做多次,效果和做一次一样。类比上,电梯按一下和按 100 下都是叫电梯(idempotent);银行转账按一下和按两下转两份钱(非 idempotent)。为什么这件事关键?因为 retry 安不安全完全取决于操作是不是 idempotent。
举一个 Agent 系统的具体例子:
# 非 idempotent —— retry 会出大事
def send_email_to_user(user_id, subject, body):
smtp.send(user_id, subject, body)
return "sent"
# retry 一次 = 用户收两封一模一样的邮件如果你给这种非 idempotent 的操作配了 retry,后果可能是网络瞬断让请求看起来失败(实际邮件已发出),retry 又发一遍,用户收到 2 封一模一样的邮件,觉得你的产品坏了。正确做法是把操作改造成 idempotent:
# Idempotent —— retry 安全
def send_email_with_dedup(user_id, subject, body, idempotency_key):
if email_log.has(idempotency_key):
return "already_sent" # 之前发过了,直接返回
smtp.send(user_id, subject, body)
email_log.record(idempotency_key)
return "sent"加一个 idempotency_key 字段(通常是 UUID),记录这次操作是否已经做过,retry 时检查这个 key,这是 production 标配。
Agent 系统里的 idempotency 陷阱汇总如下。send_email(user, body) 不 idempotent,retry 会发多次;transfer_money(from, to, amount) 不 idempotent,retry 会扣多次钱;delete_file(path) 不 idempotent,第一次成功后 retry 会报文件不存在错。反之 read_file(path) 天然 idempotent;search_web(query) 不改变外部状态,天然 idempotent;upsert_record(id, data) 写入但 by-id 去重,天然 idempotent。设计 Agent 工具时,优先做 idempotent,实在不行加 idempotency_key。这是 production Agent 必须处理的事,跟 retry 配套一起设计;没 idempotency 保证的工具,绝不允许 retry。
5. Trade-off lens · Latency / Throughput / Cost
Production engineering 第 5 大块是 trade-off 判断。所有 LLM 系统决策都最终落到三个轴的取舍:Latency 是单个请求从发出到完成的时间,用户体感慢来自这一轴;Throughput 是单位时间能处理多少请求,高峰能不能扛住来自这一轴;Cost 是每个请求花多少钱(token + compute + infra),你的钱包直接受这一轴影响。关键认知是这三个轴不是独立的,压一个会影响另外两个,trade-off 就是在这三个轴之间找你产品定位下的最优点。让我用具体场景把这套 lens 用起来。
5.1 场景 1 · 大模型还是小模型?
| Latency | Throughput | Cost | 质量 | |
|---|---|---|---|---|
| 大模型(Claude Opus / GPT-4 / DeepSeek V3) | 慢 | 低 | 贵 | 高 |
| 小模型(Claude Haiku / GPT-4-mini / DeepSeek V3 轻级) | 快 | 高 | 便宜 | 低 |
判断:简单任务用小模型(分类、提取、格式化);复杂推理用大模型。99% 项目应该是小模型为主加大模型在关键时刻被调用的混合。这就是 Ch3 学的 Planner / Executor 模式,Planner 用强模型做战略决策(少量 call),Executor 用便宜模型做战术执行(大量 call)。整体 cost 降下来,quality 持平。
5.2 场景 2 · Streaming 还是 REST?
| Latency (TTFT) | Latency (总时间) | 实现复杂度 | |
|---|---|---|---|
| Streaming | 极低 (~100ms) | 相同 | 高(前后端都得改) |
| Non-streaming | 高(等所有 token 完成) | 相同 | 低 |
判断:用户直接交互走 streaming(用户体感提升 10 倍);后台批处理走 non-streaming(简单点)。回到 Ch2 学过的 API 三种模式,这里就是 trade-off lens 应用。
5.3 场景 3 · Cache 多激进?
| Latency | Cost | Freshness | |
|---|---|---|---|
| 不 cache | 高 | 高 | 永远最新 |
| 短期 cache(5min) | 低 | 低 | 5 分钟内 stale |
| 长期 cache(1 天) | 极低 | 极低 | 可能 stale 1 天 |
判断:静态数据(weather forecast、past events)大胆 cache;动态数据(user profile、real-time price)短 cache 或不 cache。
5.4 场景 4 · Context 越长还是越短?
| Latency | Cost | 回答质量 | |
|---|---|---|---|
| 长 context(整个文档塞进去) | 高 | 极高 | 信息齐全但可能 lose-in-middle |
| 短 context(只塞 top-K 相关) | 低 | 低 | 取决于 retrieval 准不准 |
判断:retrieval 准就走短 context 加 RAG;retrieval 不准就长 context。常见错误是先决定塞多少,而不是先 eval retrieval 质量。
5.5 把 trade-off lens 用起来
下次看到一个方案,问自己三个问题。第一,这个方案在 latency / throughput / cost 三轴上是什么形状(快慢、扛不扛、贵不贵)?第二,设计者最在意的是哪一轴?为什么?第三,这个 trade-off 跟产品定位对得上吗?如果你能在自己脑子里跑这套问题,你就有了 trade-off 判断的种子。Stage 4(Ch6)会把这个种子练成本能,我们会把整个 foundations 路书学过的所有 trade-off 决策合成一张 lens 大表。这里你先建立思维框架,Ch6 我们建立决策大表。
6. 综合 · 一个完整的失败 + trade-off 决策
把 Section 4 加 Section 5 合起来,我用一个真实例子收尾:你设计一个 RAG 系统的 LLM call 层,出现 504 timeout 怎么办?错误的处理方式(vibe coder 典型反应)是看到 timeout 就重试,重试 3 次还 timeout,给用户报错。后果是用户重试 3 次共耗 3 乘 30 秒等于 1.5 分钟,用户体感非常糟;而且每次重试都付 partial token 钱,失败也要付费。
正确处理综合本章所有内容,分三层 fallback。第一次 retry 等 1 秒加 jitter 后再试,因为可能是 transient 故障。第二次 retry 不重试 LLM 原方案,而是自动 fallback 到更小的模型(Haiku / GPT-4-mini),牺牲质量换 latency 加 cost。这就是 trade-off lens 的应用,在 quality 轴退一步,换 latency 与 cost 的安全。第三次 fallback 直接返回服务暂不可用请稍后再试,不再重试,记录错误,触发 alert;承认失败比无限循环重试更对得起用户。Observability 层每个步骤记录耗时、哪一步成功、用了哪个 fallback model,这一层是 Ch5 observability 的内容。
这套设计同时用上了本章的所有概念:HTTP status 分类(知道 504 是 retryable,Section 4.1)、Retry 加 backoff 加 jitter(第一次重试,Section 4.2)、Trade-off lens(降级到小模型等于用质量换 latency,Section 5.1)、Observability(每步可见,失败可分析,见 Ch5)。这就是工程基底的判断,不是哪个写法正确,而是在 production 现实下,什么选择最对得起 latency / throughput / cost / quality 综合。
把这套思维落到一个 Agent 系统的完整数据流图,你会看到:
[ 用户 ]
↓ HTTP POST /research(question)
[ API Server ] ← Stateless,可水平扩展
↓ 推入 queue + 返回 job_id ← Async(2.1 节)
[ Queue (Redis BullMQ) ]
↓ Worker 取任务
[ Agent Worker ] ← Stateful within this job
↓ 各种工具调用 + LLM 调用
├→ [ Postgres ] (写中间结果 / 进度) ← Database(3.1)
├→ [ Redis ] (cache LLM 调用结果) ← Cache(3.2)
└→ [ Vector DB ] (RAG retrieval) ← Vector DB
↓ 完成
[ WebSocket / Email ] (通知用户)读懂这张图,你 grounded 在 production 层面做出正确 trade-off,而不是不知所云优化。
收官 · 你现在在哪 + 下一站
回头看你这一章走了什么。你拿到了 7 个 production-quality 维度,包括命名、错误处理、类型、测试、文档、抽象、Git,这是 production engineer 的代码基本功。你拿到了 sync vs async 与 stateless vs stateful 的状态与时间维度,这两个区分定义了 LLM 系统能扛多少并发、能跑多长任务。你拿到了 DB / Cache / Queue 三件套,Postgres 加 Redis 加 Vector DB 这套 90% Agent 项目通用的数据架构。你拿到了 HTTP status 分类、Retry、Idempotency 的失败处理思维,这是 production 系统的第一公民。你拿到了 Latency / Throughput / Cost trade-off lens 的决策框架,看任何方案都能跑一遍三轴问题。这就是 Stage 3 的工程基底,接下来我们要做的是把这些抽象工程能力落到一个具体的部署加监控加 OS 视角。
下一章(Ch5)进入 Stage 3 第 3 章,看不见的就调不动,部署加监控加 OS 视角。覆盖 Cloud 加 Docker 加 CI/CD baseline、Web stack 轻度、OS 与网络与 GPU minimum、Logs 与 Metrics 与 Traces 三件套、5 类 LLM-specific 可观测信号。
你完成了 foundations 的第 4 个心流断点。合上书,去练:拿一段你之前写过的 LLM call 代码,按本章 7 维度对照重构;思考你做的 Agent 系统,状态都放在哪里、失败时怎么处理、retry 是 idempotent 安全的吗。这种把抽象 mental model 应用到具体代码的练习,是 production 思维真正长出来的过程。下次回来,带着扎实的工程基底进 Ch5。
本章术语速查
Production-quality 维度:Production-quality 可读可维护可信任的代码 · Vibe coding 跑通就 OK 的代码 · Type hints / Type annotations 类型注解 · Static type checker mypy / pyright / tsc · Unit test / Integration test 单元 vs 集成测试 · Docstring / README / ADR 三种文档 · Atomic commit 一个逻辑单元的 commit · Conventional commits feat/fix/refactor 前缀 · Squash merge 合并多 commit · Linter / Formatter ruff / eslint / black / prettier · DRY / KISS / YAGNI 三大设计原则缩写
状态与数据:Sync 同步等结果 · Async 异步凭证 · Streaming 边算边返回(本质 sync 特殊形式)· Stateless 不记得历史 · Stateful 保留连接状态 · Persistence 持久化 · Database 持久存储加查询 · SQL / NoSQL 两大门派 · KV / Document / Vector / Graph NoSQL 四子类 · Cache 暂存贵东西 · Stale 缓存过期 · Queue 异步缓冲 · Worker / Job 后台消费 / 任务单位
失败与 trade-off:HTTP status code 三位数(2xx / 4xx / 5xx)· Retry 失败后再试 · Backoff 间隔逐渐拉长 · Jitter 加随机扰动 · Idempotency 多次做效果同一次 · Idempotency key 去重 UUID · Latency 单请求时间 · TTFT 首 token 时间 · Throughput 单位时间处理量 · Cost 单请求成本 · Fallback 主路径失败的备用 · Rate limit / Timeout 429 / 504 对应
完整词汇见 术语表 /glossary。
参考文献
Mode B · 必看视频
无。SWE production 主题分散,无单一合格视频 cover。本章全 Mode A。
Mode A · 引用出处
- DeepSeek 全栈 JD · Rust / C++ / TypeScript / Python 中至少一门加优秀的设计能力与代码质量意识 · 收录 teaching-system/research/foundations/synthesis.md
- Anthropic Full-Stack RL JD · production-quality 编程实践 baseline
- OpenAI Applied Evals SWE JD · judgment to create reusable systems
深 dive 资源(可选)
SWE 设计哲学经典:
- John Ousterhout · A Philosophy of Software Design · 抽象层次设计 / complexity 管理 / 命名哲学 · Ch 2-6 强烈推荐,1.6 节抽象的中庸之道这本书讲得最透
- Martin Kleppmann · Designing Data-Intensive Applications · Ch 2-3(reliability / scalability / maintainability 三性)· DB / Cache / Queue 设计经典,Section 三的具体技术选型这本书展开
- Frederick Brooks · The Mythical Man-Month · 软件工程基础经典(虽然 50 年前写的,核心洞察依然成立)
Production engineering 实战:
- Anthropic Engineering Blog · 真实 production 案例加工程实践
- Stripe Engineering Blog · Idempotency / retry / API 设计典范
- GitHub Engineering Blog · 大规模系统实践
语言进阶:
- Python:PEP 8 风格指南 ·
ruffmypy文档 - TypeScript:TypeScript Handbook
- Rust:The Rust Programming Language Book
- Git:Pro Git Book
下一章
下一章(Ch5)继续 Stage 3 工程基底,看不见的就调不动,部署加监控加 OS 视角。把 LLM 系统真正推到线上加让它出问题时你看得见。