《AI Agent 工程实战》18.3 上线、观察与迭代 + 真实 API 端到端冒烟(可选)

收官节分三块收束全卷:上线检查单、上线后的观察节奏(第 1 小时/第 1 天/第 1 周各看什么、何时回滚)、把线上失败与成本异常回流成评测样本与优化项的迭代闭环。再给出可选的真实 API 端到端冒烟流程与代码,明确标注需自备密钥、本机未执行,并用假模型跑同一脚本做离线等价验证,最后给全卷回顾与通往卷三的路线。

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分层配置 + 密钥注入 + 真/假模型路由与降级
3REST API + SSE 流式接口 + 幂等与统一错误契约
4JWT / API Key 认证 + workspace 行级租户隔离 + 高危工具审批
5Postgres schema、迁移、事务并发与查询优化
6Redis 检索缓存 + 语义缓存 + 按租户 token 配额限流
7任务队列:run 异步跑 + 重试死信 + 定时 digest
8引擎内并发 + 背压与信号量 + 取消超时与泄漏防护
9事件协议 schema 共享 + TS 最小聊天 UI + 断线重连续传
10MCP 客户端接入 + 工具设计模式 + 工具沙箱
11记忆服务化:写入流水线 + 检索注入 + TTL/隐私/删除
12评测集与评分器 + 离线回放 + CI 质量门
13OTel GenAI 埋点 + 指标看板 + 告警与 on-call
14延迟剖析 + 成本优化 + 吞吐压测找拐点
15输入护栏 + 输出护栏 + 审计日志
16双栈镜像瘦身 + compose 编排 + 健康检查与优雅退出
17CI/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 宕机/队列积压 · 下一节:回到目录 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「agent」更多文章

  1. 《AI Agent 高级编程》目录
  2. 《AI Agent 高级编程》11.3 全景回顾:从零到框架的完整路径与展望
  3. 《AI Agent 高级编程》11.2 真实 API 冒烟:假模型 vs 真模型量化差距