18.3 上线、观察与迭代 + 真实 API 端到端冒烟(可选)
18.1 给了「该有什么」的清单,18.2 证明了「坏了会怎样」。最后一节要回答的是:怎么把它交出去,并且交出去之后还能越跑越好。上线不是终点,而是一个新的起点——真正的系统是在线上一次次失败、一次次修正中长出来的。
本节把 ResearchCopilot 推进到「可运营」:给出上线检查单与上线后的观察节奏,把线上失败与成本异常回流成评测样本与优化项,写清可选的真实 API 端到端冒烟流程(本机未执行),并用假模型跑同一脚本做离线等价验证;最后回顾全卷、指向卷三。
18.3.1 上线检查单:发布前逐条打勾
这份检查单收束 17 章的发布流程与 18.1 的评审清单,是按下「发布」前的最后一道闸:
| 检查项 | 通过标准 |
|---|---|
| CI 全绿 | lint / test / 质量门(12.3)全过,构建产物可复现 |
| 镜像可回滚 | 上一版镜像 tag 保留,回滚命令演练过(17.2) |
| 配置与密钥 | 生产密钥来自 secret 注入,配置项与分层配置一致(2.1) |
| 迁移已演练 | schema 迁移可 expand-contract 回滚(17.3) |
| 容量已核对 | worker 数、连接池、配额按 18.1 估算配置 |
| 探针就绪 | liveness / readiness 分离,readiness 依赖 DB / Redis |
| 告警就绪 | 关键告警有阈值、负责人、runbook(13.3) |
| 演练通过 | 18.2 三个故障演练全过,改进项清零 |
| 评测基线 | 当前评测分数已记录,作为灰度对比基线(12.1) |
| 回滚预案 | 明确的回滚触发条件与执行人 |
十条里最容易被跳过、也最致命的是最后一条:回滚预案。发布前想清楚「什么情况回滚、谁来回滚、怎么回滚」,比把新功能做得多漂亮更重要——因为线上不会给你临场想清楚的时间。
18.3.2 上线后的观察节奏:1 小时 / 1 天 / 1 周
上线后的注意力是分阶段的。第 1 小时看「有没有立刻坏」,第 1 天看「稳不稳、贵不贵」,第 1 周看「有没有悄悄变差」:
| 时段 | 看什么 | 回滚 / 动作触发 |
|---|---|---|
| 第 1 小时 | 错误率、p99 延迟、readiness、队列深度、降级计数 | 错误率超基线 2 倍或 p99 破 SLO → 立即回滚 |
| 第 1 天 | 成功率、成本、缓存命中率、租户分布、告警噪音 | 成本超预算 50% 或质量门下降 → 回滚或降级 |
| 第 1 周 | 评测分数漂移、失败案例聚类、容量拐点、用户反馈 | 分数持续下降或拐点提前 → 触发优化迭代 |
三个时段的观察重心不同,但都遵循同一条原则:先看「变了没有」,再看「为什么变」。第 1 小时的指标是绝对值(有没有超阈值),第 1 周的指标是趋势(相对基线在往哪个方向漂)——后者更隐蔽,也更需要 12.1 的评测基线来对照。
18.3.3 迭代闭环:让线上成为最好的教材
上线之后,系统会产生两样最宝贵的原料:失败案例和成本数据。它们不该被丢掉,而该被回灌进两个闭环。
闭环一:线上失败 → 评测集样本(呼应 12.1)。 每次线上出现一次真实的坏结果——引用幻觉、工具误用、超时降级、答案跑偏——都把它固化成一条评测用例:记下输入、期望行为、断言点,加进回归网。下一次改动前先跑一遍,这条用例就从「一次事故」变成了「一道防线」。线上失败是评测集最好的来源,因为它真实、且已经被证明会发生。
闭环二:成本异常 → 优化项(呼应 14.2)。 某个租户、某类问题的成本突然飙升,不要只当成「花多了」,而要归因:是哪个工具调用膨胀了?是 input token 因为历史累积涨了?还是缓存没命中导致重复检索?找到原因后,就得到一个具体优化项——调 TTL、加裁剪、换小模型、提高缓存命中率。第 14.2 章讲的降本手段,靶子正是从这些异常里照出来的。
两个闭环合起来,就是一个完整的迭代飞轮:线上暴露问题 → 问题变成资产(用例 / 优化项)→ 资产让下一次发布更稳、更省。没有这个飞轮,系统上线后就只是「维持」;有了它,系统才会「生长」。
18.3.4 真实 API 端到端冒烟(可选):流程与代码
前面全卷都用假模型,最后做一次真实冒烟是值得的——它证明「接线真的通」。流程四步:
| 步骤 | 做什么 | 通过标准 |
|---|---|---|
| 1 | 从环境变量读密钥,构造真实模型适配器 | ANTHROPIC_API_KEY 已设且不落盘 |
| 2 | 用真模型跑一次三跳问答 | 循环收敛,产出带引用的回答 |
| 3 | 校验引用可回溯 | 每条 [doc_id#chunk_id] 都指向真实块 |
| 4 | 打印用量与成本 | input/output token 与成本可读 |
⚠️ 前提:需自备密钥、会消耗额度,本机未执行。 下面这段代码是「怎么做」的说明,本节没有运行它,没有发起任何真实调用,正文里也没有任何密钥字面量:
"""18.3 真实 API 冒烟(可选;需自备密钥、会消耗额度,本机未执行)。"""
import os
from anthropic import Anthropic
from smoke3 import ( # 复用离线脚本的语料、工具与校验
CORPUS, QUESTION, SYSTEM, call_tool, run_smoke, schemas, validate_citations,
)
def to_anthropic(messages):
out = []
for m in messages:
if m.role == "system":
continue
if m.role == "tool":
out.append({"role": "user", "content": [
{"type": "tool_result", "tool_use_id": m.tool_call_id, "content": m.content}]})
elif m.role == "assistant" and m.tool_calls:
blocks = ([{"type": "text", "text": m.content}] if m.content else []) + [
{"type": "tool_use", "id": tc.id, "name": tc.name, "input": tc.arguments}
for tc in m.tool_calls]
out.append({"role": "assistant", "content": blocks})
else:
out.append({"role": m.role, "content": m.content})
return out
class AnthropicModel:
"""真实模型适配器:把 ModelResponse 契约翻译成 Anthropic messages API。"""
def __init__(self, model: str) -> None:
self.model = model
self._client = Anthropic(api_key=os.environ["ANTHROPIC_API_KEY"]) # 只从环境读
def complete(self, messages, tools) -> "ModelResponse":
system = next((m.content for m in messages if m.role == "system"), "")
resp = self._client.messages.create(
model=self.model, max_tokens=1024, system=system,
messages=to_anthropic(messages),
tools=[{"name": t["name"], "description": t["description"],
"input_schema": t["parameters"]} for t in tools])
calls = [ToolCall(b.id, b.name, b.input) for b in resp.content if b.type == "tool_use"]
text = "".join(b.text for b in resp.content if b.type == "text")
return ModelResponse(
message=Message(role="assistant", content=text, tool_calls=calls),
stop_reason="tool_use" if calls else (resp.stop_reason or "end_turn"),
usage={"input_tokens": resp.usage.input_tokens,
"output_tokens": resp.usage.output_tokens})
def main() -> None:
assert os.environ.get("ANTHROPIC_API_KEY"), "请先 export ANTHROPIC_API_KEY"
model = AnthropicModel(model=os.environ.get("RC_MODEL", "claude-sonnet-4-5"))
final, steps, uin, uout = run_smoke(model)
print(final)
for cite, ok in validate_citations(final):
print(f" {cite:<20} {'OK' if ok else 'MISSING'}")
print(f"input={uin} output={uout} cost=${uin/1e6*3 + uout/1e6*15:.6f}")
if __name__ == "__main__":
main()
18.3.5 离线等价验证:用假模型跑同一个冒烟脚本
真实调用要花钱、要联网,但脚本逻辑本身能不能先验证?能——用假模型跑同一个 run_smoke,就能证明「读密钥 → 跑三跳 → 校验引用 → 打印用量」这条链路没有写错。下面这段离线脚本本机真跑(venv Python 3.14.6),类型定义沿用 18.2.1:
"""18.3 端到端冒烟:真/假模型共用同一条三跳问答通路(离线默认)。
类型定义(Message / ToolCall / ModelResponse)沿用 18.2.1,此处省略。"""
import json
import re
import time
CORPUS: dict[str, list[str]] = {
"gil-and-cpu": ["CPU 密集型任务受 GIL 限制,多线程无法并行,应改用多进程。",
"I/O 密集型任务在等待期间会释放 GIL,因此多线程仍有收益。"],
"asyncio-basics": ["asyncio 适合 I/O 密集型任务,事件循环在单线程内调度协程。"],
"memory-model": ["Python 的引用计数在对象引用归零时立即回收。"],
}
def search(query: str, k: int = 2) -> str:
terms = [t.lower() for t in query.split() if t]
scored = sorted(
((sum(txt.lower().count(t) for t in terms), d, c, txt)
for d, chunks in CORPUS.items() for c, txt in enumerate(chunks)),
key=lambda x: -x[0])
hits = [{"doc_id": d, "chunk_id": c, "score": s, "snippet": txt}
for s, d, c, txt in scored[:k] if s]
return json.dumps(hits, ensure_ascii=False)
def read(doc_id: str, chunk_id: int) -> str:
return CORPUS[doc_id][chunk_id]
TOOLS: dict[str, tuple[dict, object]] = {
"search": ({"type": "object", "properties": {"query": {"type": "string"},
"k": {"type": "integer"}}, "required": ["query"]}, search),
"read": ({"type": "object", "properties": {"doc_id": {"type": "string"},
"chunk_id": {"type": "integer"}}, "required": ["doc_id", "chunk_id"]}, read),
}
def schemas() -> list[dict]:
return [{"name": n, "parameters": p} for n, (p, _) in TOOLS.items()]
def call_tool(tc) -> str:
return TOOLS[tc.name][1](**tc.arguments)
class FakeModel:
"""脚本驱动:每步要么给出一串 ToolCall,要么给出终结文本。"""
def __init__(self, script: list, name: str = "fake") -> None:
self._script, self._i, self.name = list(script), 0, name
def complete(self, messages, tools) -> "ModelResponse":
step = self._script[self._i]
self._i += 1
if isinstance(step, str):
return ModelResponse(message=Message(role="assistant", content=step))
if isinstance(step, ModelResponse):
return step
return ModelResponse(message=Message(role="assistant", tool_calls=list(step)),
stop_reason="tool_use")
PRICES = {"input": 3.0, "output": 15.0}
SYSTEM = ("你是严谨的研究助理。回答每个论断时在句末标注来源 [doc_id#chunk_id],"
"只能引用 search/read 返回过的块。")
QUESTION = "Python 里 CPU 密集和 I/O 密集的并发方案该怎么选?"
FINAL = ("CPU 密集型受 GIL 限制,应改用多进程。[gil-and-cpu#0]\n"
"I/O 密集型适合 asyncio,事件循环单线程调度协程。[asyncio-basics#0]")
def turn(calls, text="", inp=0, out=0) -> "ModelResponse":
return ModelResponse(
message=Message(role="assistant", content=text, tool_calls=list(calls)),
stop_reason="tool_use" if calls else "end_turn",
usage={"input_tokens": inp, "output_tokens": out})
def fake_script() -> list:
return [
turn([ToolCall("t1", "search", {"query": "GIL CPU 密集 多线程", "k": 2})], inp=120, out=18),
turn([ToolCall("t2", "read", {"doc_id": "gil-and-cpu", "chunk_id": 0})], inp=210, out=12),
turn([ToolCall("t3", "search", {"query": "asyncio 事件循环", "k": 2})], inp=320, out=16),
turn([ToolCall("t4", "read", {"doc_id": "asyncio-basics", "chunk_id": 0})], inp=420, out=10),
turn([], text=FINAL, inp=560, out=64),
]
def run_smoke(model, max_steps: int = 8):
messages = [Message(role="system", content=SYSTEM),
Message(role="user", content=QUESTION)]
steps, usage_in, usage_out = [], 0, 0
for i in range(max_steps):
resp = model.complete(messages, schemas())
usage_in += resp.usage.get("input_tokens", 0)
usage_out += resp.usage.get("output_tokens", 0)
messages.append(resp.message)
if not resp.message.tool_calls:
steps.append(f"[{i}] final {resp.message.content.splitlines()[0]} ...")
return resp.message.content, steps, usage_in, usage_out
for tc in resp.message.tool_calls:
messages.append(Message(role="tool", content=call_tool(tc), tool_call_id=tc.id))
steps.append(f"[{i}] tool {tc.name}({tc.arguments})")
return "", steps, usage_in, usage_out
def validate_citations(final: str) -> list[tuple[str, bool]]:
return [(f"{d}#{c}", d in CORPUS and int(c) < len(CORPUS[d]))
for d, c in re.findall(r"\[([\w-]+)#(\d+)\]", final)]
def main() -> None:
model = FakeModel(fake_script(), name="fake-offline")
t0 = time.perf_counter()
final, steps, uin, uout = run_smoke(model)
cost = uin / 1e6 * PRICES["input"] + uout / 1e6 * PRICES["output"]
print(f"模型: {model.name}(离线,未触网)")
print("== 步骤轨迹 ==")
for s in steps:
print(" ", s)
print("== 最终回答 ==")
print(final)
print("== 引用可回溯校验 ==")
rows = validate_citations(final)
for cite, ok in rows:
print(f" {cite:<20} {'OK' if ok else 'MISSING'}")
print(f" 引用 {len(rows)} 条,全部可回溯 = {all(ok for _, ok in rows)}")
print("== 用量与成本 ==")
print(f" input={uin} output={uout} cost=${cost:.6f} 耗时={(time.perf_counter()-t0)*1000:.1f}ms")
if __name__ == "__main__":
main()
真跑输出:
模型: fake-offline(离线,未触网)
== 步骤轨迹 ==
[0] tool search({'query': 'GIL CPU 密集 多线程', 'k': 2})
[1] tool read({'doc_id': 'gil-and-cpu', 'chunk_id': 0})
[2] tool search({'query': 'asyncio 事件循环', 'k': 2})
[3] tool read({'doc_id': 'asyncio-basics', 'chunk_id': 0})
[4] final CPU 密集型受 GIL 限制,应改用多进程。[gil-and-cpu#0] ...
== 最终回答 ==
CPU 密集型受 GIL 限制,应改用多进程。[gil-and-cpu#0]
I/O 密集型适合 asyncio,事件循环单线程调度协程。[asyncio-basics#0]
== 引用可回溯校验 ==
gil-and-cpu#0 OK
asyncio-basics#0 OK
引用 2 条,全部可回溯 = True
== 用量与成本 ==
input=1630 output=120 cost=$0.006690 耗时=0.2ms
五步收敛,两条引用全部可回溯。这就是离线等价验证的意义:真实调用前,先用假模型证明「读密钥 → 跑三跳 → 校验引用 → 打印用量」这条链路本身没写错。真模型跑的将是同一个 run_smoke,唯一变的是「谁来实现 complete()」——换后端不改接线,正是第 2 章 Model 协议带来的收益。
18.3.6 全卷回顾:18 章各自解决了什么
| 章 | 解决的问题 |
|---|---|
| 1 | 定需求与 3 个核心用例,画出架构总览,建 monorepo 与 CI 骨架 |
| 2 | 分层配置 + 密钥注入 + 真/假模型路由与降级 |
| 3 | REST API + SSE 流式接口 + 幂等与统一错误契约 |
| 4 | JWT / API Key 认证 + workspace 行级租户隔离 + 高危工具审批 |
| 5 | Postgres schema、迁移、事务并发与查询优化 |
| 6 | Redis 检索缓存 + 语义缓存 + 按租户 token 配额限流 |
| 7 | 任务队列:run 异步跑 + 重试死信 + 定时 digest |
| 8 | 引擎内并发 + 背压与信号量 + 取消超时与泄漏防护 |
| 9 | 事件协议 schema 共享 + TS 最小聊天 UI + 断线重连续传 |
| 10 | MCP 客户端接入 + 工具设计模式 + 工具沙箱 |
| 11 | 记忆服务化:写入流水线 + 检索注入 + TTL/隐私/删除 |
| 12 | 评测集与评分器 + 离线回放 + CI 质量门 |
| 13 | OTel GenAI 埋点 + 指标看板 + 告警与 on-call |
| 14 | 延迟剖析 + 成本优化 + 吞吐压测找拐点 |
| 15 | 输入护栏 + 输出护栏 + 审计日志 |
| 16 | 双栈镜像瘦身 + compose 编排 + 健康检查与优雅退出 |
| 17 | CI/CD 流水线 + 灰度回滚 + 不中断 schema 迁移 |
| 18 | 容量与架构评审 + 故障演练 + 上线运营迭代 |
这张表本身就是答案:卷一把 minagent 从 20 行循环写成一台引擎,卷二把它装进一个真实可用的产品。18 章不是 18 个孤立的知识点,而是一条从「能跑」到「能上生产」的完整路径——配置、鉴权、数据、缓存、队列、并发、记忆、评测、可观测、安全、部署、发布,一块都不缺。
18.3.7 通往卷三:从「跑起来」到「跑得远」
卷二到这里结束,但 Agent 工程还有更远的地方。卷三要处理的是「当你不再满足于自己造轮子时」:拿主流框架(PydanticAI、LangGraph、Claude Agent SDK 等)重写这套服务,逐处对照「框架替你做了什么、代价是什么」;再往前走,是 durable execution、computer use、A2A、Agent RL 这些 2026 年最活跃的前沿方向。有了卷二这份「真实可用的服务」打底,卷三的每一处对照你都会看得格外透——因为你亲手把这条链路跑通过一遍。
收束一句话:卷二证明了「一个 Agent 产品该由哪些部分构成、每部分该做到什么程度」。这套心智模型不依赖任何框架,会一路跟着你走。现在,回到目录,看看这 55 篇是怎么串起来的。
阅读导航:上一节:18.2 故障演练:模型超时/DB 宕机/队列积压 · 下一节:回到目录 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。