第1节 项目总览:需求→PR→审查全自动流水线架构设计
一句话总结:用一张架构图定义最终项目——票据库为脊椎、分诊/修复/审查三个子循环、看板与通知、验证门禁;模块划分与技术选型的全部理由,外加仓库骨架和基建代码的第一行。
本文导航
- 一、为什么是这条流水线
- 二、总体架构:五层一张图
- 三、数据主线:一张票据的一生
- 四、模块划分与技术选型
- 五、仓库骨架与基建代码
- 六、踩坑实录:架构层面的三个坑
- 七、里程碑与架构自检
- 小结
一、为什么是这条流水线
前面12章我把循环工程拆成了一块块零件:第3章解剖了Agentic Loop的五个步骤,第7章做了验证器和Maker/Checker,第9章搭了SQLite票据库,第10章用worktree解决了并行冲突,第11章跑通了无人值守,第12章补上了Evals和观测。零件都齐了,但一直是散装的。
这一章做一件更刺激的事:把所有零件拼成一条真流水线——Issue进来,PR出去,中间没人。
我给自己定的验收标准非常具体:凌晨2点定时任务启动,拉取仓库里所有未处理的GitHub Issue,分诊循环判断哪些能自动修,拆成带验收条件的子任务写进票据库;三个修复工人并行领票,各自在独立worktree里跑TDD修复循环;改完的代码必须先过pytest+ruff+mypy三件套,再过双模型审查,全绿才开Draft PR;第二天早上我打开Slack,看到的是一排PR和一份看板日报,而不是一地鸡毛。
为什么选"Issue→PR"这个场景?三个原因。第一,它是闭环:需求有明确入口,验收有明确出口,天然适合用票据库串状态。第二,它是刚需:我维护着一个几千星的开源小项目,每天睁眼十几个Issue,手动triage已经把我逼疯了。第三,它够综合:分诊是理解型任务,修复是生成型任务,审查是评判型任务,三种任务对模型能力的要求完全不同,正好逼我把"模型分层"这种架构决策落到代码里。
先说结论,这个项目最终跑出来的数字:30个真实Issue的压测集上,全自动成功率87%,单任务平均成本$0.06,平均时长2.5分钟,人工介入率13%。这些数字怎么来的,后面每一节都会拆开讲。
二、总体架构:五层一张图
架构图我用一张图说清楚。核心思想就一句话:SQLite票据库是脊椎,所有循环都是围着它转的器官。
五层各自的职责边界我反复强调过,这里再钉一次:
| 层 | 职责 | 不该做的事 | 失败会怎样 |
|---|---|---|---|
| 输入层 | 把外部事件变成统一格式 | 不做任何判断 | 事件丢失,整条线饿死 |
| 分诊层 | 分类auto/semi/manual+拆解子任务 | 不写代码 | 垃圾票进库,工人空转烧钱 |
| 执行层 | 在worktree里修复+自测 | 不越scope改文件 | 并行冲突,PR互相覆盖 |
| 验证层 | 确定性三件套+双模型审查 | 不放水 | 坏代码流出去,信任崩塌 |
| 交付层 | PR+看板+通知 | 不自作主张merge | 人看不到结果,等于白干 |
这张表里"不该做的事"比"职责"更重要。我最惨的一次教训就是分诊循环里顺手调了一次修复API"帮Issue作者看看"——那次直接烧了$4多,票还没写。每一层只做自己的事,这是流水线不失控的第一道闸。
三、数据主线:一张票据的一生
架构图是静态的,真正让系统活起来的是票据的状态流转。整个项目里唯一有状态的实体就是票据(Ticket),其他模块全部无状态、可随时重启。这个设计决定让我的系统获得了两个超能力:任何进程崩了重启不丢数据;任何循环都可以横向扩容。
状态机只有五个状态:pending → running → done/failed/escalated。看着简单,但有两个细节是我在实际踩坑之后才补上的:
第一,running必须带租约(lease)。工人认领票据时写入自己的worker_id和认领时间,其他工人看到这张票还挂在别人名下就不碰。我一开始没做租约,凌晨有个工人进程OOM挂了,那张票永远卡在running,流水线第二天就"漏票"了。加了租约之后,超时30分钟的running票自动回滚到pending,谁都可能死,系统得能自愈。
第二,escalated不是失败,是安全出口。三次修不好就升级人工,这条路径必须存在而且必须通畅,否则doom loop会把预算烧穿。第3节会专门讲升级逻辑的实现。
四、模块划分与技术选型
4.1 模块与章节对照
这个项目不是从零发明,前面每一章的产出物在这里各就各位。对着下面这张表读,你会看到整门课的知识是"拼图"关系而不是"知识点列表":
| 模块 | 职责 | 复用章节 | 本项目落点 |
|---|---|---|---|
| 分诊循环 | Issue解析+任务拆解+写票 | 第5章、第11章第3节 | triage_module.py |
| 修复循环 | TDD修复+自测+交回 | 第6-7章 | fix_worker.py |
| 验证体系 | 三件套+双模型审查 | 第7章 | validators.py、maker_checker.py |
| 票据库 | 状态机+原子认领+租约 | 第9章第5节 | ticket_store.py |
| worktree编排 | 并行隔离 | 第10章第3节 | worktree_orchestrator.py |
| 看板 | 状态/成功率/成本聚合 | 本章 | dashboard.py |
| 通知 | Slack/升级 | 第11章第6节 | notify.py |
| Evals | 回归+退化告警 | 第12章 | evals/ |
| 日志基建 | 双输出+按月分割 | 第6章第1节 | logging_config.py |
这种"每章一个零件、最后拼整车"的结构,正是循环工程这门课和普通AI教程最大的区别:普通教程教你调API,循环工程教你造系统。第12章讲过,84%的人在用AI但只有29%敢信AI,差距就在这——用的门槛是提示词,信的门槛是工程。
4.2 技术选型:每个选择都有理由
选型表我先贴出来,再解释几个容易被挑战的决定:
| 组件 | 选择 | 理由 | 放弃的方案 |
|---|---|---|---|
| 语言 | Python 3.12 | 生态最全、团队都会 | Go(部署爽但LLM生态弱)、Rust(杀鸡牛刀) |
| 包管理 | uv | 秒级装依赖、lockfile可复现 | poetry(慢)、pip(无锁) |
| 状态管理 | SQLite | 单文件、WAL并发安全、零运维 | Postgres(运维成本杀鸡用牛刀)、Redis(内存贵且不持久) |
| LLM接入 | OpenAI兼容+Anthropic双后端 | 一套client跑所有模型 | 只绑一家(被锁价) |
| 验证 | pytest+ruff+mypy | 确定性信号,零幻觉 | 让LLM自评(不可信) |
| 通知 | Slack Webhook | 5行代码接入 | 邮件(没人看) |
| 部署 | Docker Compose | 单机标准化、迁移即复制 | K8s(本项目规模纯属自虐) |
| 配置 | pydantic-settings | 环境变量+类型校验 | yaml手解析(类型裸奔) |
两个决定我多说两句。
为什么是SQLite而不是Postgres?一开始我也觉得"企业级"就得配Postgres,直到我算了一笔账:这条流水线峰值也就3个工人并发读写,SQLite开WAL模式后读写并发完全够,单文件备份就是cp一下,容器迁移零负担。而Postgres意味着多一个有状态服务、多一份运维、多一个可能崩的组件。第9章第5节那个431赞的票据库案例用的也是SQLite。当然它有边界:如果我要做多机部署或每秒几百写入,必须换。架构没有银弹,只有匹配。
为什么要双后端LLM接入?因为模型分层策略(第7章第7节)在这个项目里是刚需:maker用gpt-4o干重活,checker用gpt-4o-mini做审查,judge用gpt-4o-mini判验收。不同任务挂不同key不同baseurl,client必须统一抽象,而且每次调用的baseurl、端口、key末6位、模型名、输入输出长度、起止时间、耗时都要进日志——这不是锦上添花,这是成本核算和事后追溯的生命线。第5节压测时我能精确算出每轮调优省了多少钱,全靠这行日志。
五、仓库骨架与基建代码
先看目录结构,整个项目的骨架长这样:
ai-loop-pipeline/ ├── pyproject.toml # uv管理,依赖锁死在uv.lock ├── docker-compose.yml # 第6节部署用 ├── .env.example # 密钥模板,真实.env永不进Git ├── data/ │ └── tickets.db # SQLite票据库(挂载卷持久化) ├── logs/ # 按月分割的日志 + prompts/原始提示词 ├── prompts_backup/ # 每次LLM调用的完整输入输出落盘 ├── src/ai_loop_course/ │ ├── logging_config.py # 日志基建(本节) │ ├── llm_client.py # LLM统一客户端(第6章第2节) │ ├── ticket_store.py # 票据库状态机(第9章) │ ├── triage_module.py # 分诊循环(第2节) │ ├── fix_worker.py # 修复循环工人(第3节) │ ├── worktree_orchestrator.py │ ├── validators.py # pytest+ruff+mypy三件套 │ ├── maker_checker.py # 双模型审查 │ ├── dashboard.py # 看板(第4节) │ └── night_pipeline.py # 总入口:分诊→修复→交付 └── tests/ # 流水线自身的测试第一段要写的代码永远是日志基建。全项目所有模块都从它拿logger,规范我在第6章第1节定过:控制台+文件双输出、按月分割、保留12个月、进程退出打印量化总结。这里给出完整实现:
# src/ai_loop_course/logging_config.py"""日志基建:控制台+文件双输出,文件按月分割,只保留最近12个月。"""importloggingimporttimefrompathlibimportPath LOG_DIR=Path("logs")LOG_DIR.mkdir(exist_ok=True)_RETENTION=12# 保留12个月历史日志classMonthlyFileHandler(logging.FileHandler):"""按月分割:文件名直接带年月,跨月写第一条日志时自动切换文件。"""def__init__(self,directory:Path):self._dir=directorysuper().__init__(directory/f"loop-{time.strftime('%Y-%m')}.log",encoding="utf-8")defemit(self,record:logging.LogRecord)->None:month=time.strftime("%Y-%m")ifmonthnotinself.baseFilename:# 跨月了:关旧文件开新文件self.close()self.baseFilename=str(self._dir/f"loop-{month}.log")self.stream=Nonesuper().emit(record)self._cleanup()def_cleanup(self)->None:"""删除12个月前的历史日志文件,防止磁盘被慢慢吃满。"""cutoff=time.strftime("%Y-%m",time.localtime(time.time()-365*24*3600))forfinself._dir.glob("loop-*.log"):iff.stem[-7:]<cutoff:# loop-2025-08 → 2025-08f.unlink(missing_ok=True)defget_logger(name:str)->logging.Logger:"""全项目统一入口:同名logger只挂载一次handler,防止重复输出。"""logger=logging.getLogger(name)iflogger.handlers:returnlogger logger.setLevel(logging.INFO)fmt=logging.Formatter("%(asctime)s | %(levelname)-7s | %(name)s | %(message)s",datefmt="%Y-%m-%d %H:%M:%S",)console=logging.StreamHandler()console.setFormatter(fmt)monthly=MonthlyFileHandler(LOG_DIR)monthly.setFormatter(fmt)logger.addHandler(console)logger.addHandler(monthly)returnlogger第二段是LLM调用的全字段日志和退出总结。llm_client.py的核心我在第6章第2节写过,这里只贴本项目新增的调用摘要部分——程序退出时自动打印token和耗时的量化统计,这是我自己加的"下班打卡":
# src/ai_loop_course/llm_client.py(节选:调用登记与退出总结)importatexitfromdataclassesimportdataclass,field@dataclassclassCallRecord:"""一次LLM调用的全字段记录,字段与日志一一对应。"""model:strinput_len:int# 提示词字符数 + 附带文件字节数output_len:intduration:float# 秒_CALLS:list[CallRecord]=[]defsummarize_calls()->None:"""进程退出时打印量化总结:token最大/最小/平均、耗时同口径。"""ifnot_CALLS:returnins=[c.input_lenforcin_CALLS]outs=[c.output_lenforcin_CALLS]durs=[c.durationforcin_CALLS]n=len(_CALLS)print("="*62)print(f"📊 LLM调用总结:共{n}次 | 总耗时{sum(durs):.1f}s")print(f" 输入长度 max={max(ins)}min={min(ins)}avg={sum(ins)//n}")print(f" 输出长度 max={max(outs)}min={min(outs)}avg={sum(outs)//n}")print(f" 单次耗时 max={max(durs):.1f}s min={min(durs):.1f}s avg={sum(durs)/n:.1f}s")print("="*62)atexit.register(summarize_calls)# 无论正常退出还是跑完main,都会打卡跑起来之后,一次完整调用的日志长这样(key只露末6位,原始提示词全文落盘到prompts_backup/):
2026-09-29 02:14:01 | INFO | llm | 调用开始 baseurl=https://api.openai-proxy.com:8443 key=…f3a9c2 model=gpt-4o 输入=3215字符+0字节 2026-09-29 02:14:03 | INFO | llm | 调用成功 baseurl=https://api.openai-proxy.com:8443 key=…f3a9c2 model=gpt-4o 输入=3215字符 输出=842字符 开始=2026-09-29 02:14:01 结束=2026-09-29 02:14:03 耗时=2.1s 提示词=logs/prompt-20260929-021401-7c2f.txt 2026-09-29 02:14:03 | INFO | llm | 调用成功 baseurl=https://api.openai-proxy.com:8443 key=…b71e04 model=gpt-4o-mini 输入=1187字符 输出=403字符 开始=2026-09-29 02:14:02 结束=2026-09-29 02:14:03 耗时=0.9s 提示词=logs/prompt-20260929-021402-9d11.txt有了这两段基建,后面六节的每个循环模块都是"拿logger开箱即用",不用重复操心观测问题。这也是我想传递的工程习惯:基建先行,业务后写。反过来的顺序我试过,写到第4个模块就得回头重构日志,代价更大。
六、踩坑实录:架构层面的三个坑
坑1:一开始设计了消息队列。我最早画架构图时塞了个RabbitMQ,觉得"企业级就得有MQ"。真跑起来发现:分诊和修复之间隔着SQLite,队列里根本没有第二个消费者,MQ成了纯摆设,还多了个会崩的组件。教训:架构图上每一条线都必须有真实的数据流动,为了看起来专业而画的线,最后都会变成故障点。删掉MQ后系统少了200行胶水代码。
坑2:验证层和执行层画混了。第一版里工人在修复循环里"顺手"跑pytest,跑挂了又回到修复。问题是checker模型也是工人在调——相当于运动员自己当裁判,模型改测试文件作弊就是从这里钻的空子(第3节细讲)。重构后验证层独立成门禁:工人交回代码时只交diff,验证器由主流程调度,测试文件对工人只读。裁判必须站在运动员的循环外面,这句口语化的原则比任何UML都好记。
坑3:忘了给"分诊不写票"留路径。第一版所有Issue都强制写票,遇到一个纯讨论型Issue(“大家觉得API该怎么设计”),分诊拆出一个"实现大家讨论的API"子任务,工人硬着头皮修了3轮失败升级人工。我早上看到票时哭笑不得。修复:分诊增加manual和ignore出口,不明确就生成澄清评论回贴到Issue,宁可问,不要猜——这条原则贯穿整个项目。
七、里程碑与架构自检
整章的施工顺序我排成了七个里程碑,每个节点都有可验收的产物,做完一个勾一个——这本身就是把循环工程用在项目管理上。七个里程碑背后是第3章第4节的目标设计公式:每个里程碑都是一个"可验证的成功态"——M3的验收标准是抽检准确率≥90%而不是"分诊能用",M4是压测集75%而不是"工人能跑"。写不清验收标准的里程碑,等于没拆。
| 里程碑 | 交付物 | 验收标准 |
|---|---|---|
| M1 基建就绪 | logging_config + llm_client | 日志双输出、全字段调用记录、退出总结 |
| M2 票据库 | ticket_store 状态机 | 并发100次claim无一重复、租约可回收 |
| M3 分诊 | triage_module | 30个Issue抽检准确率≥90% |
| M4 工人 | fix_worker + maker_checker | 压测集成功率≥75% |
| M5 看板 | dashboard + 异常检测 | 日报自动推Slack |
| M6 部署 | Dockerfile + compose | 换机10分钟跑通 |
| M7 复盘 | retro脚本 + 路线图 | 月度量化画像一键生成 |
每次部署前我跑同一个60秒自检脚本,逐项确认五层的"生命体征",任何一项不过就不放行主流程:
# scripts/health.py"""架构自检:票据库/日志/LLM/GitHub/Slack逐项检查,全过才exit 0。"""importsqlite3frompathlibimportPathfromai_loop_course.configimportsettingsfromai_loop_course.logging_configimportget_logger logger=get_logger("health")CHECKS:list[tuple[str,bool,str]]=[]defcheck(name:str,fn)->None:"""跑一项检查:异常记❌,正常记✅并保留细节。"""try:CHECKS.append((name,True,fn()))exceptExceptionase:CHECKS.append((name,False,str(e)[:100]))defping_llm()->str:"""LLM连通性:发一次1 token的最小请求,验证baseurl+key。"""fromai_loop_course.llm_clientimportLLMClient LLMClient(settings).generate("ping",model="gpt-4o-mini",max_tokens=1)returnf"baseurl={settings.baseurl}:{settings.port}key=…{settings.key[-6:]}可用"check("票据库",lambda:f"{sqlite3.connect(settings.db_path).execute('SELECT COUNT(*) FROM tickets').fetchone()[0]}张票可读")check("日志目录",lambda:(Path("logs").mkdir(exist_ok=True),"logs/可写")[1])check("LLM连通",ping_llm)check("GitHub",lambda:gh_rate_limit(settings.github_token))check("Slack",lambda:slack_test(settings.slack_webhook))forname,ok,detailinCHECKS:print(f"{'✅'ifokelse'❌'}{name}{detail}")(logger.infoifokelselogger.error)(f"自检{name}:{detail}")raiseSystemExit(1ifany(notokfor_,ok,_inCHECKS)else0)✅ 票据库 208张票可读 ✅ 日志目录 logs/可写(loop-2026-09.log 按月分割正常) ✅ LLM连通 baseurl=https://api.openai-proxy.com:8443 key=…f3a9c2 可用 ✅ GitHub token有效(ratelimit剩余4992) ✅ Slack webhook有效(#ai-pipeline) 2026-09-29 01:59:00 | INFO | health | 自检完成:5/5通过,放行流水线"自检不过不放行"这条规则救过我一次:某天凌晨LLM代理证书过期,没有自检的话,分诊会整批失败全部走兜底转manual,我早上要手动补30个Issue的分诊。有自检,流水线在第一步停下并发了Slack告警,证书续上后手动重跑,10分钟恢复。流水线的第一行代码应该是"确认自己能干活",而不是"开始干活"——这和工人领票前先验租约,是同一个哲学。
小结
- 五层架构:输入→分诊→执行→验证→交付,SQLite票据库是脊椎,所有循环围绕它转,其他模块全部无状态可重启。
- 票据状态机:
pending→running→done/failed/escalated五状态,running带租约防漏票,escalated是安全出口不是失败。 - 选型克制:SQLite而非Postgres、Compose而非K8s——每一条"不用更重的方案"的决定,都让系统少一个会崩的组件。
- 基建先行:日志按月分割留12个月、LLM调用全字段记录、原始提示词落盘、退出打印量化总结,这些在写第一个业务模块之前就要就位。
下节预告
架构图定了,脊椎立了,下一节开始造第一个器官:第13章第2节《需求分诊模块:Issue解析与任务拆解循环》——拉取Issue、判断可自动化性、拆解成验收条件明确的子任务、写票入库,分诊提示词与pydantic结构化输出的完整实现。
如果觉得本文对你有帮助,欢迎点赞、收藏、关注三连!
本系列持续更新中,关注不迷路~
文章编号:第13章第1节 | 总进度:91/120 | 预计阅读时间:11分钟