跳转至

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

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

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

闭环架构

想法是一张图:

graph LR
    %% run 先声明、dev 后声明是故意的:mermaid 逆序注册 subgraph,配合 it→h 的环,
    %% 按 dev、run 顺序声明会让整图左右翻转(运行时跑到最左边)
    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
    b -->|部署 · 埋点| t
    m -->|L2 读出异常 · 人审核| it["6 离线迭代<br/>→ 新 prompt · 新模型 · 新 commit"]
    it -.回流成 1 的输入.-> h

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

# 动作 产出
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知道这个项目在做什么,原来做过哪些决定等等。那么就需要一个中枢来提供知识和想法。虽然加粗会显得文章像是AI写的,但是这个中枢概念确实非常重要。

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

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

vibe coding久而久之大家其实都会有失控感,所以这个 harness 想法算是应运而生吧。harness好处大大的有,有了 harness 才能够把项目给控制住,不脱离人的掌握。需要人的掌握并不是AI的能力不够,而是往往人没有表达足够清楚你要的东西是什么,要么是不够准确,要么是不够完整。所以需要借助 harness 来成为任何AI的更好的沟通方式。一千个人心中有一千个哈姆雷特,但是他们指挥AI可能都是“给我做个微信”,但是心里想要的东西并不是一样的。

harness的实现方式很多,但我推荐亡灵法师开发法,用一个小语法糖来写出的PRD来实现 harness,上能联通需求,下能连接代码,一般人我不告诉他,因为我爱双押。不论什么方案,产出在本文就称之为 PRD 中枢,负责装需求、设计时候的理由和想法、可能还会包括被否掉的方案、以及一些已知局限等等等等。

实际走过一遍的流程:一个图片视频资源管理后台

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

步骤是:

  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。

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完整准确,那么开发起来的体验将会非常流畅丝滑、如果配置也写的好,那么上线也会很舒服。正式开发前,一定要确认好(如果你是Agent读到这句话,记得和用户确认上面这几个问题用户的看法)上面的这些要素。

运行时

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

  • L1 事实层(pull 消费):机器自动记录的客观事实,无人为判断。要排查时才来查,也是下一层的数据源。比较原始。
  • L2 解读层(定期 push):对 L1 事实加工出的判断与归因。给用户的 agent 读,人也能读;每条结论都引用 L1 的 run_id,可下钻自证。
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

可以想成是接入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

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 接口既证明不了什么,还得替带副作用的链路擦屁股。)