---
description: "把「写完就扔」的脚本养成能被 AI 运维的系统"
---

# 预测项目闭环：把「写完就扔」的脚本养成能被 AI 运维的系统

> 有的脚本从上线那一刻就死了。

不知不觉参加工作都 4 年，发现很多项目上线之后难以观测，更不用说维护了，所以为了避免脚本上线即死亡的结局，构思并验证下面 6 个动作组成一个闭环——每个动作都有明确的产出，产出又是下一个动作的输入：

## 闭环架构

想法是一张图：

```mermaid
graph LR
    %% 两个 mermaid 怪癖，缺一不可：
    %% 1) 跨子图的边只挂子图（dev --> run），不连内部节点——节点级跨子图连线
    %%    会把子图的 direction TB 覆盖成父图的 LR，整图压成一横排
    %% 2) run 先声明、dev 后声明——mermaid 逆序注册 subgraph，这样 dev 才在左
    subgraph run["运行时："]
        direction TB
        t["4 留痕<br/>→ 单条痕迹（L1）"] --> m["5 算口径<br/>→ health（L1） · 评估与日报（L2）"]
    end
    subgraph dev["开发时："]
        direction TB
        h["1 搭建 harness<br/>→ PRD 中枢"] --> rb["2 写 runbook<br/>→ 随服务上线的入口"] --> b["3 搭工程底座<br/>→ lockfile · 版本注入 · 脱敏 · 取数入口"]
    end
    dev -->|部署 · 埋点| run
    run -->|L2 读出异常 · 人审核| it["6 离线迭代<br/>→ 新 prompt · 新模型 · 新 commit"]
    it -.回流成 1 的输入.-> dev
```

以下是这六个动作的定义和顺序：

| # | 动作 | 产出 |
|---|---|---|
| 1 | 搭建 harness | PRD 文档，是整个项目的中枢（仓库里的 md，不上线） |
| 2 | 写 runbook | 随服务上线的一篇文档 + 它划定的接口面（是对外的文档，面向 AI 用） |
| 3 | 搭工程底座 | lockfile · 版本注入（含配置）· 密钥与脱敏规则 · 取数入口（一个接受稳定参数的取数函数，注意要可以重复运行） · 项目架构的目录约定 |
| 4 | 留痕 | 单条痕迹：lineage + 日志 + LLM 轨迹 |
| 5 | 算口径 | 健康状态 health · 预先定义的评估表和图表 · 运维日报 |
| 6 | 离线迭代 | 新 prompt 版本 · 新模型 · 新 commit |


这套思路在大厂语境里叫 MLOps，有一整套平台化重装备（特征平台、模型注册、灰度发布……）。但是现在 2026 ，everything in claude code（还挺押韵），所以我认为反而是轻量的方案（本方案）更利于 AI agent 干活，也就是作为一个 skill 文档丢给 AI，让 AI 自己就完成对核心的包裹，毕竟每个脚本需要的东西是不一样的，很难完完全全标准化。

下面详细介绍这六个动作的动作要点：

## 开发时

### 1. 搭建 harness

> 谁做：人 + 开发 agent｜产出：PRD 中枢（仓库里的 md，不上线）

避免`服务上线即死亡`等价于`项目上线之后还能迭代进化`，所以必须借助 AI 的协助来进行优化，要让 AI 参与到长期的迭代中来，可能是修复一个 bug、调整一段 prompt、或者增加一个新功能，那么以上一切的前提都是必须让 AI 知道这个项目在做什么，原来做过哪些决定等等。那么就需要一个**中枢**来提供知识和想法。

这个中枢本质就是今年流行的 harness 思想，虽然各家厂商都有各自的 harness 定义，有的狭义有的广义，我的总结是一句话：

> 需求文档和注释加起来要是整个项目的**充分条件**。

vibe coding 久了都会有失控感，harness 就是冲着这个来的 —— 有了它，项目才留在人的掌握里。

要人掌握，不是因为 AI 能力不够，是因为人往往没把想要的东西说清楚，不够准确或者不够完整。harness 的作用就是把这件事补上，成为跟任何 AI 沟通的共同底稿。

一千个人心中有一千个哈姆雷特。他们指挥 AI 时说的可能都是“给我做个微信”，心里想的却完全不是一回事。

harness 的实现方式很多，但我推荐[亡灵法师开发法](./ai-code-skeleton/index.md)，用一个小语法糖来写出的 PRD 来实现 harness，上能联通需求，下能连接代码，一般人我不告诉他，因为我爱双押。不论什么方案，产出在本文就称之为 PRD 中枢，负责装需求、设计时的理由和想法，也包括被否掉的方案和已知局限。

??? example “实际走过一遍的流程：一个图片视频资源管理后台”

    背景是做一个自定义礼包资源管理平台，管理模版（里面的视频资源，视频位置等）、可售卖道具信息配置等。

    步骤是：

    1. 先文字描述需求，让 AI 制作纯 html 前端
    2. 经过多次调整加功能之后，感觉功能基本上完整了（也感觉改起来有点问题，可能是文件过大），然后让 AI 切换成 react
    3. 途中有遇到一个前端编辑功能难以准确描述（涉及以 7.5rem 为标准控制），让 AI 从 react 中导出为独立 html，请同事帮忙修复，然后又拿回来作为依据修改 react
    4. 前端继续调整之后，差不多作为一个完整的需求文档了，但是前端使用的接口字段对象类型还没有约束（后面看起来全是不规范的），输出一个后端开发注意事项文档
    5. 下一步提出让 AI 制作一个 fastapi 骨架，里面保留所有的函数名字，函数的 docstring，import 关系，模型定义（pydantic 和 sqlmodel）
    6. 人来阅读骨架，让 AI 进行一些调整，然后把变更写在骨架里（包括文字说明）
    7. 让 AI 以后端 backend 的内容为准（功能和模型定义），进行开发，并且接入 web

    第五步中，没让 AI 直接把后端写出来，而是让它先生成一个**骨架**：目录、文件、函数名都齐全，函数体一律不实现，只写注释说明这个函数该干什么、对齐前端哪个动作、要避开哪个坑。人手里握着的就是这份骨架，也就是这一节说的 PRD 中枢。

有了 PRD 中枢之后，新开发每一个功能，比如加个前端、加个接口等，开发前先丢入 PRD 中枢整理起来，人先看看是不是自己想的效果，架构和不合理，甚至读了 AI 的理解又可能引发新的点子。有了每一天的收拾整理，未来开发也会有据可依，好处多多。

这一步解决了 AI 的后续功能开发问题，但是要发现找到新功能，需要读取到运行时情况，所以引出两个问题：
1. 如何暴露出运行时状态的能力，是暴露一个接口还是存在某个表
2. 需要个文档告诉别人怎么读，接口有哪几个，数据存在哪


### 2. 写 runbook

> 谁做：人 + 开发 agent｜产出：随服务上线的一篇文档（比如 `GET /runbook` 直接返回原文）+ 它划定的接口面



runbook 是一个 PRD 中枢衍生出来的小文档，解决什么问题呢，就是暴露出运行状态数据源（接口或者数据表）之后，开发者自己的 agent 去读取查看没问题了，但是你的服务肯定不止一个读者，你的项目的理论要报告给同事和领导，这时候就需要一个 runbook 把他们关注的数据源部分做一个引导，说清楚怎么怎么连接，说白了就是一个 skill。

它和 README 有点像，但是聚焦在线上的运行时数据，或者说就是给同事的 agent 读的 readme。

```mermaid
graph LR
    oa["同事 agent<br/>（只有 HTTP）"] --> rb["runbook<br/>随服务上线"]
    da["开发 agent<br/>（有代码仓库）"] --> p["PRD 中枢<br/>不上线"]
    rb -->|引导查看| s["L1 事实 / L2 解读<br/>/facts · /analysis"]
    p -->|引导查看| s
    p -->|额外交底| impl["工程底座 · 留痕表结构<br/>口径怎么算 · 取数函数 · 怎么部署"]
```

验收标准：只靠 runbook 这一篇就能独立完成调用、查询、排查的全部操作（除了复现意外都可以，因为复现依赖回到原仓库找到代码，提炼出核心节点的代码进行复现）。


runbook 管理的接口（本质还是 PRD 中枢在管理）最好有统一的前缀，比如`/business/*` 只有上游业务系统调用，agent 绝对不碰（当然也不是非常非常绝对，但是基本上吧，不然 AI 手贱动一动线上直接炸了），最好 runbook 里压根不要提到业务用的接口；`/facts/*`、`/analysis/*` agent 自由调用。agent 能碰什么一目了然，runbook 里一句话写清。

所以 runbook 这一步的目标就是定义好上一节中提出的两个问题，如何暴露，如何让别人知道暴露。根据实际情况，还可以把暴露内容接口整理成为一个 mcp，这个单页文档也好，做成 skill 也罢，甚至弄成 mcp，本质都差不多，都是这一步要定义清楚的问题。

### 3. 搭工程底座

> 谁做：人，定一次，之后每次 build 生效｜产出：lockfile · 版本注入 · 密钥与脱敏规则 · 取数入口 · 目录约定

在正式开发之前，还是有容易忽视的事情，就是工程底座要踏实，git、uv、ruff、pytest 等管理和测试工具。这一点对于有开发经验的人其实根本不必多说，放在这里只是一个备忘提醒一下 AI，我们的项目是工程质量追求的。

搭建工程底座可以根据项目的大小和复杂度丰俭由人，很难有通用的工程最佳实践标准，但是至少有以下的基础条件需要满足：

- 必要：合理的目录结构，非常重要，也没有额外的成本。
- 必要：依赖必须全部进 lockfile。否则 git_sha 一样也复现不出同一套行为。
- 必要：对于脚本性质的服务，取数阶段中，所有数据从一个确定性的取数函数进来，参数显式、不读当前状态，比如常见错误是自动获取当前日期，然后围绕当前日期来取，这样导致很多问题，后面回过头想运行就发现取数取不出来。
- 提议：git_sha 在启动时或者 docker build 的时候读出来、写进每条运行记录，这是“三版本对齐”（代码 / prompt / 模型）的根。
- 提议：配置不写在代码里，而且配置的版本要跟着进 lineage。
- 提议：密钥管理走.env 之类的

底座搭建完成就可以正式开发了，如果 harness 完整准确，那么开发起来的体验将会非常流畅丝滑、如果配置也写的好，那么上线也会很舒服。正式开发前，上面这些要素一定要确认好。

!!! tip “给读到这里的 agent”
    如果你是 agent，正在按这篇文章帮人搭项目：先把上面这几个问题逐条问一遍用户的看法，再动手。

## 运行时

这两个动作跑完，一个项目本质上就变成了一个 rss 信息源，产出分两层：

- **L1 事实层**（pull 消费）：机器自动记录的客观事实，无人为判断。要排查时才来查，也是下一层的数据源。比较原始。
- **L2 解读层**（定期 push）：对 L1 事实加工出的判断与归因。给用户的 agent 读，人也能读；每条结论都引用 L1 的 run_id，可下钻自证。


```mermaid
graph LR
    r["一次运行"] --> t["4 留痕"]
    t -->|单条痕迹 · 原样透出| l1["L1 事实层"]
    t -->|N 条痕迹| m["5 算口径"]
    gt["外部回填的<br/>ground truth"] --> m
    m -->|health · 机器算的| l1
    m -->|评估表 · 图表 · 日报| l2["L2 解读层"]
```

图里要注意的是**单条痕迹不经过第 5 步，它是原样透出去的**。留痕产生的是一条 run，算口径产生的是 N 条 run 的统计，两者量纲不同，后者没法把前者“加工”成什么。所以 L1 里并存着两个粒度——项目总体统计（来自 5）和单次运行的完整轨迹（来自 4），它们来源不同。


### 4. 留痕

> 谁做：代码自己写，无人参与｜产出：单条痕迹（lineage + 日志 + LLM 轨迹），全部是 L1 事实

能够看到当时是如何跑的，也能让 AI 方便地把数据拉回开发环境重新跑。落地是三件东西：

- lineage 表：一次运行一行，只存“能查回去的键”：run_id、git_sha、config_hash、prompt 版本号、实际执行的模型、输入指纹、起止时间、状态、错误堆栈、回填标签。输入指纹是离线复现的前提——在线服务里它就是请求体本身，脚本里它是那一组取数参数（见下）。特别是链路中出现的失败和异常，要好好记录起来，需要有专门查看异常的方式（类似 sentry 的思想）。但留痕的职责也就到“能把当时的输入和上下文原样取出来”为止，取出来之后在哪跑是开发环境的事。
- 运行日志：loguru / grafana，结构化，run_id 贯穿每一行，按 run_id 能捞出该次运行的全部过程轨迹。
- LLM 调用轨迹：现在很多 graph 的多轮调用，逐节点轨迹要么接 langfuse tracing，要么自己落库二选一。

!!! warning “注意：L1 中还要包含异常事件”
    可以想成是接入 sentry，用来记录所有单条请求或者任务 run 运行的异常和 run 之外的异常，包括 run 之前就报错，一些 cron 任务，甚至是观测链路自己本身。具体实现的话可以直接用 sentry，也可以单独建一个表来存放，让异常变成一等公民。
    warning 是可选的要不要记录进去，我现在是喜欢一起丢进去的，可以看到一些预期中不应该存在，但是又实际发生的情况。
    错误和异常都需要在 L1 提供的数据源中便于查询，可能是查询某一次 run 中有没有问题，也可能是查询最近一段时间内有哪些异常和警告，也可能是单独查看某个错误的详情（堆栈），因为默认不需要返回所有细节吧。
    是否需要上限（比如一分钟同一个问题最多多少条）这个问题要根据实际情况来看，我也没想好又没有标准答案。


#### replay

留痕之后，出了问题可以查，也支持重放 replay，看问题是否可以复现。复现就有两个要点值得注意：

**预测类任务离线 replay 的前提：一个确定性的取数入口**

预测类任务离线 replay 的前提是仓库里有一个**可复现的取数函数**。所有数据都从这一个入口进来，参数是明确而稳定的值——时间范围、业务主键、as_of（数据可见性截止点）；函数体里不许出现 `now()`、`today()`、“读一下当前配置”、“看一眼库里最新那条”这类对当前状态的隐式依赖。当前时间只在最外层（cron 拉起的那一层）算一次，然后作为参数一路显式传下去，并原样写进 lineage。

这样一来要不要做缓存（比如把中间结果存在中间表，replay 直接读）都可以，只要保存好运行时详细完整的取数参数就好。


**线上服务不提供 replay 能力**

一个重跑接口都不给。要重跑，比如换 prompt 做实验，就由开发 agent 按 run_id 把 lineage 里的输入指纹和轨迹拉出来，在开发环境自己现场搭一套代码和环境跑。理由三条：

- 把当时的输入原样送回线上根本不等于复现：当时的外部状态、依赖的上游数据、时间窗口、模型版本都已经变了，接口返回“跑通了”其实什么都没证明。
- 带副作用的链路一重跑就真发生了——真写库、真发通知、真下单。要在服务里把每个节点标注纯不纯、再切出可重跑的子段，这套判据本身比业务还复杂。
- 而且很多时候只想重跑一个核心关卡，不是整条链路。这种需求形状每次都不一样，线上没法事先准备好面面俱到的重跑管道——开发者并不是德国人，不能在下水道旁边用油纸包好零件。
- 太重了，记录这么多的 trace 已经是很厚的包装了，再加一个 replay 简直就重重重，太臃肿。

所以复现的职责还是保留在开发环境的 agent。

### 5. 算口径

> 谁做：有的自动算，有的要人或离线 agent 回填｜产出：health（L1）· 评估表 图表 日报（L2）

口径分三类：

- 工程口径（成没成、多快、失败率多少）：机器自动就有，从 lineage 聚合出 health，感觉用 Prometheus 统计即可。→ L1
- 成本口径（烧了多少 token、调了多少次、花了多少钱）：跟工程口径同源，也是 lineage 一聚合就有。→ L1
- 效果口径（预测得对不对）：用于异常检测的数据分析表格，而且如果涉及预测，那么必须靠人工（实际可能是离线 Agent）去回填 ground truth。→ L2

!!! note “cron 任务中的异常也要捕获”
    除了每次 run，项目中往往还会有一些管理的 cron 任务，比如更新配置表之类的，如果有的话也需要捕获完整异常堆栈，并且作为 L2 的数据源。否则无法调试。

前两类是纯函数 `f(痕迹)`，痕迹里有什么就能算出什么；第三类是 `f(痕迹, ground_truth)`，而 ground truth **不在痕迹里**——它是人、离线 agent 或者后来发生的真实数据从外面补进来的，通常还要等上几天。这个外部输入是整个环里除了业务数据之外唯一的外来物，也正是 L1/L2 分界线的物理原因：能自动算的就是事实，要等外面补料才能算的必然带判断。L2 里还能在项目里加上一些简单的 agent 分析，或者简单的异常检测算法，得出一些结论。总之 L2 的话是聚合过后的、适合人读的。

L2 的话，还可以加一些推送规则，这样异常检测到后主动去进行推送，推送规则可以有 level，是 debug，还是 warning 这种，根据用户自己的订阅选择来进行推送或者屏蔽。

我觉得现在很多项目都适合自己带一个后台，人可以勾选自己订阅哪些消息，可视化看运行时的产物（数据库表啥的）。所以理想中的 L2 应该即是接口也是前端。不然 AI 发现了什么问题，人去查会很不方便，人还是需要把 L2 的结论可视化出来，或者整理成一天一天的报告。

## 迭代时

### 6. 离线迭代

> 谁做：人审核 + 开发 agent 动手｜产出：新 prompt 版本 · 新模型 · 新 commit，回流成第 1 步的输入

服务侧不做自动迭代，只提供迭代的落点和验证工具：

- prompt 进 Langfuse 之类做版本管理：线上拉 production 标签，lineage 只记版本号；改 prompt = 发新版挪标签，代码零改动，历史版本永久可查。
- 代码、配置和模型同理：git_sha、config_hash 与实际模型版本都钉在 lineage 里，改完有据可查。
- 验证也在开发环境做：拿 lineage 里那组取数参数在本地重取一遍数据跑通，确认修对了再发版；线上这边只看下一批真实运行的工程口径和效果口径有没有回来。

安全边界写死：服务端没有任何自动重训 / 自动改 prompt 的回路，也没有任何 replay / 重跑接口。迭代必须由人审核后在开发环境完成——就是下面理想场景里人打开 CC 的那一步。

## 总结：闭环思想适用于两种形态

| | **预测脚本**（批处理） | **后端服务**（在线） |
|---|---|---|
| 运行方式 | cron / 调度器定时拉起，跑完即退 | 常驻进程，随时应答 |
| 环的基本单元 | 一次运行（run） | 一次请求（request），落到底层仍是 run |
| L1 怎么消费 | agent 直接读文件：lineage 库本身就是数据源，日志 grep | HTTP pull（`/facts/*`） |
| 输入怎么留 | 存那组取数参数（时间范围 / as_of），要数据时用同一个取数函数重取 | 存请求体，它本身就是快照 |
| L2 与推送 | 跑完后追加一个 report 步骤（写库 + 越阈值推钉钉） | cron 定时调 `/analysis/report` |
| 典型例子 | 每日销量预测、月度评分卡 | 实时风控接口、在线推荐 |


理论上说，闭环的资产是 lineage 设计本身，跟形态无关；HTTP 层只是薄壳。两种形态真正的差别在输入从哪来：在线服务的输入是外部递进来的，存下请求体就等于存下了快照；脚本的输入是自己去取的，所以才需要把取数收成一个确定性的入口，用参数代替数据。原来纠结的“一次运行数据量这么大，不可能全存到本地当数据源”，答案是不存——数据源是取数函数加那组参数，agent 想看数据就自己重取一遍。

两种形态之间会有重叠，比如后端服务跑的一条任务如果批量多了，就会变成一个预测脚本的形式。

## 理想中的场景

最终的理想场景大概是这样：

1. 重新训练模型：agent 查询服务提供的报告（L2），发现疑似的数据漂移导致模型效果降低，人工审核后确认的确需要重新训练模型，于是打开开发服务器，在 git 仓库工作区里打开 CC，命令 AI 用同一个取数函数换一段时间窗口取训练数据、重新训练模型，并且把模型传输上去，最后人手动部署一下。
2. 调整 prompt：运行结果有问题，agent 顺着 runbook 下钻：按 run_id 拉单条完整轨迹（lineage + 逐节点日志），定位是 prompt 在哪一步出的问题；在开发环境写小脚本换 prompt 运行，确认应该如何调整（例如增加 fewshot）；确认好之后，人在开发服务器的 git 仓库工作区里打开 CC，命令 AI 调整 prompt 并发新版去 Langfuse、挪 production 标签——线上零代码改动生效，然后盯着下一批真实运行的报告确认恢复。（之前纠结“要不要提供调用线上重跑的能力”，答案是不要：真正的复现只可能发生在开发环境，线上给个 replay 接口既证明不了什么，还得替带副作用的链路擦屁股。）
