42 天 71 次提交之后,我重新看了一遍自己的架构决策

42 天 71 次提交之后,我重新看了一遍自己的架构决策

「AI Agent 工程化实战」系列收官:13 篇写过的技术点,哪些事后觉得对、哪些现在想改、重写一遍会怎么排优先级。
「AI Agent 工程化实战」系列 · 14(收官) 项目源码:Ticnix/weather-travel-recommend-system 基于气象大数据的出行推荐系统,AI Agent 全栈项目。FastAPI + PostgreSQL(TimescaleDB/pgvector/PostGIS) + Redis; LangGraph + MCP + Skill + RAG,对接 DeepSeek API;React/Vue 前后端分离,实现 3D 天气可视化、智能出行穿搭推荐。 (项目仍在更新中)


TL;DR

前面 13 篇都在讲"某个点怎么做的"。这一篇反过来,讲这些点连起来之后,我看到了什么

  1. 一个真实的时间尺度git log 数出来 71 次提交、Day 1 → Day 42、2026-08-23 到 2026-09-17,实际日历跨度 26 天。这不是"做了三个月"的项目,很多决策是在赶进度时做的------而赶进度时做的决策,事后回看质量两极分化明显。
  2. "事后觉得对"的决策有一个共同特征 :它们都是把不确定性关进笼子------一键回退、离线测试、纯函数审计、可切换模式。共同点不是"技术上更先进",而是"出问题时我有退路"。
  3. "现在想改"的决策也有共同特征 :它们都是为了写得快而留下的隐含约定 ------handle_error 不可达、overlap 静默失效、skill_loader 没有调用方、两条建表路径。全部是"声明了但没被验证"。
  4. 重写的话,优先级会这么排 :先定不变式 (什么东西永远不许破坏),再写验证(怎么知道它没被破坏),最后才写功能。这个项目的顺序恰好是反的。

目录


一、先把数字摆出来

不凭印象说话,先量一遍。以下都是脚本实际统计的(排除 venv / htmlcov / __pycache__):

代码规模

目录 文件数 行数
app/services 42 9,483
tests 35 6,508
app/routers 15 2,225
skills 13 1,244
app/core 9 846
app/models 15 532
app/tasks 8 514
app/schemas 11 458
mcp_server 3 310
其他 --- 约 460
合计 162 22,891

功能面

  • MCP 工具:7 个get_weather / get_forecast / search_news / search_knowledge / web_search / plan_travel_route / recommend_outfit
  • 本地工具:3 个(带用户态,见第 03 篇)
  • 多 Agent 领域:5 个(weather / outfit / route / itinerary / knowledge)
  • Skill:4 个 ;知识库文档:16 个
  • API 路由:15 个/api/v1/*
  • 数据库迁移:11 个(0001 → 0011)

时间线

sql 复制代码
总提交数(非 merge)   : 71
含 DayN 标记的提交     : 42   (Day 1 ~ Day 42)
首次提交               : 2026-08-23
最后提交               : 2026-09-17
实际日历跨度           : 26 天

有个数字我觉得值得先记下来:tests 目录 6,508 行,app/services 9,483 行 。测试代码是业务代码的 69%。这个比例在个人项目里算很高了------而它的代价和收益,正是后面两节要谈的。


二、事后觉得对的:四个"把不确定性关进笼子"的决策

我把 13 篇里有印象的决策过了一遍,发现"事后觉得对"的那批有个共同点:它们都不让系统处在"没有退路"的状态。逐个说。

① 架构上线先做"一键回退"

第 10 篇写过:多 Agent 架构(Supervisor + 按领域分组工具)上线时,AGENT_MODE 只是个配置项:

python 复制代码
# Agent 架构模式(Day 41):
#   single ------ 单 Agent,意图分类 + 全量工具(旧架构)
#   multi  ------ Supervisor 多智能体,按领域分组工具、并行执行后汇总
# 默认 single:新架构上线的第一步是"能一键回退",
# 出问题改一行配置就能退回旧路径,不用重新发版。
AGENT_MODE: str = "single"

当时为什么这么做 :其实理由很朴素------我没把握新架构一定更好。而"没把握"的时候,最不该做的就是把旧路径删掉

事后为什么觉得对 :因为它让"要不要上新架构"这个决定变得可逆了。可逆的决定可以快速做、错了快速改;不可逆的决定才需要反复论证。项目里凡是"改一行就能退回去"的地方,我都敢直接动手。

这个道理可以推广:chat() 按模式分派那几行代码,买的不是功能,是"敢试"的权利。

② 测试跑在"离线环境"里

第 10 篇的三条离线保障(conftest.py):

  • 独立测试库 weather_db_test(且必须在 import app 之前设 DB_URL
  • 每个用例后 TRUNCATE ... RESTART IDENTITY CASCADE 清表
  • respx 拦截全部外部请求,未匹配就报错

事后为什么觉得对 :第三条最关键。respx 默认行为是"没 mock 的请求直接失败",这个选择意味着测试不可能偷偷连生产 API。一条"默认拒绝"的策略,比十条"记得写 mock"的规范管用得多。

实测数据:4 个 AI 相关测试文件,81 用例 / 49.01s / 全绿,全程不连库、不连 Redis、不连 LLM。

③ 把"模型会听话"换成"纯函数审计"

第 13 篇里我最喜欢的一段,来自 itinerary_planner/SKILL.md

markdown 复制代码
- **不把「模型会听话」当作正确性依赖**:提示词里写"雨天不要排户外"只是建议,
  所以在生成之后做一次审计,把坏天气日的户外项换成室内候选。
  「雨天不排户外」因此从"希望如此"变成"返回结果里不可能出现"。
- **审计逻辑是纯函数**:LLM 输出不稳定,指望它对断言等于自找 flaky;
  把可验证的约束抽成纯函数(`needs_indoor` / `is_outdoor` / `audit_plan`),
  LLM 只负责创意部分,两者都能各测各的。

事后为什么觉得对 :这是全项目唯一一处主动把 LLM 的输出当成不可信输入的地方。别的链路多多少少都指望"模型应该会照做",只有这里明确承认"它可能不照做,所以我要检查"。

一句话总结:LLM 是供应商,不是下属。它交的货要验。

④ 同一个 SQL 只写一遍

第 12 篇的 DAILY_AGGREGATE_SELECT------同一段聚合 SQL 要在三处一致(生产连续聚合 / 测试普通视图 / 离线对账)。做法是抽成一个常量,三处共用。

事后为什么觉得对:因为"三处保持一致"这种事,靠人是保不住的。抽成常量的那一刻,一致性从"约定"变成了"结构"。

不过这条我要给个诚实的补充------抽成常量只是让它"容易一致",并不保证"一定一致" 。真正的保障还是得靠测试:本项目这块的测试覆盖是不足的 (这一点在第 12、13 篇都体现过)。所以准确的说法是:方向对,但只做了一半


三、现在想改的:四个"声明了但没被验证"的坑

反过来说"想改"的这批,共同点也很清晰:它们都在代码里"声明"了某个行为,但没有任何东西验证这个行为真的发生

我把 13 篇里暴过的坑列个总表,按"危害 / 修复成本"排:

声明在哪 实际行为 危害 修的成本
handle_error 节点不可达 图结构里有这个节点 永远进不去 中:错误路径静默缺兜底 低:改条件边
split_textoverlap 从未生效 配置项 + docstring overlap=0=50 输出相同 中:跨边界语义丢失 低:改 3 行
skill_loader 没有调用方 完整实现四个函数 app 里零调用 低:能力闲置 低:接线
两条建表路径不一致 init_db() + Alembic create_all 建的库 upgrade 会炸 高:新环境踩雷 中:收敛路径

重点说两个

handle_error 不可达------我在第 09 篇做了完整分析。图里明明挂了节点和条件边,看着挺完备:

python 复制代码
graph.add_conditional_edges(
    "classify_intent",
    _should_error,
    {"error": "handle_error", "agent": "agent"},
)

_should_error 只挂在 classify_intent 后面 。而 classify_intent 自己已经把异常全 catch 了、降级到关键词匹配------所以它永远不会产出 error。真正的失败点是 _agent_node(LLM 调用),它失败时返回 {"error": ...},但出口接的是 _should_continue ,那个函数只看 tool_calls,见末条没有就直接 END

结果:LLM 失败时,answer 是空字符串,兜底节点一次都没运行过。

这个坑的教训不是"要写对图",而是:图的连通性用眼看看不出来。 一个节点存在 ≠ 它可达。这类问题只有把图跑一遍(或用断言检查可达性)才能发现。

overlap 从未生效 ------第 13 篇实测的,证据是决定性的:split_text(text, 300, 0) == split_text(text, 300, 50) 返回 True

它的可怕之处不在后果(跨边界句子被切两半),而在它伪装得太好

  • 配置项 EMBED_CHUNK_OVERLAP = 50 是个正经数字
  • docstring 写着"相邻块之间保留 overlap 字符"
  • 代码第 58 行确实有 current = current[-overlap:]
  • 81 个测试用例,没有一个覆盖 split_text

每一环看着都对,所以它活了三个月。


四、两类决策的分界线在哪

把上面两批放在一起看,分界线非常清楚:

markdown 复制代码
事后觉得对  →  不确定性被【显式关进笼子】
                一键回退 / 默认拒绝 / 纯函数审计 / 抽成常量

现在想改    →  不确定性被【默认忽略】
                节点存在就以为可达 / 参数存在就以为生效
                函数实现就以为被调用 / 两种建表方式就以为等价

用一句话概括这条分界线:

前者问的是"如果它坏了,我怎么知道";后者问的是"它应该是对的,对吧"。

而这两种提问方式,对应的其实是同一件事的两种态度:你信不信"看代码"能保证正确性。

我的结论是:不能,尤其是这个项目这种形态。

原因是这个项目里有太多"看不见的耦合":

  • LLM 的行为看不见(所以要做审计)
  • 图的连通性 看不见(所以 handle_error 死了都没人发现)
  • 配置项是否生效 看不见(所以 overlap 静默失效)
  • 代码是否被调用 看不见(所以 skill_loader 悬空)

这四类东西有一个共同点:它们都不报错。 错了就错了,日志干净,测试全绿,只是行为和你以为的不一样。

那怎么办

我现在的答案很朴素:凡是"看不见的耦合",都要给它配一个"看得见"的成本。

看不见的东西 配一个看得见的验证
LLM 会不会照做 生成后跑纯函数审计
图节点是否可达 断言"从入口出发能到达 handle_error"
配置项是否生效 把它调成 0,看输出变不变
函数是否被调用 grep 调用点,或加使用计数

第二行和第三行,成本都低到几十行甚至一行。而它们能拦下的,恰恰是最难靠 review 发现的那类问题。


五、重写一遍,我会怎么排优先级

如果重来一次,我会把顺序调成这样。注意这不是"功能优先级",而是**"什么先做"的优先级**。

第一步:先写"不变式清单",不写功能。

在写第一行业务代码之前,先列清楚"什么东西永远不许破坏"。比如这个项目现在能总结出的几条:

  1. 用了 asyncio.run() / 独立事件循环的地方,不许有连接池缓存 (该坑已三次现身:Celery / 测试 conftest / alembic,一律 NullPool)。
  2. MCP 工具名在三个地方(server.py、注册表、测试)必须逐字一致
  3. 有用户态的数据检索,WHERE user_id = :user_id 不许省
  4. 外部服务调用必须有降级路径,且降级路径必须真的能返回东西(第 08 篇的 DDG 兜底就是"形式上降级"的反例)。

这四条不是我事后编的,是这 26 天里踩出来的。但踩出来才总结,代价就是每条都真的坑过一次。

第二步:每条不变式配一个测试,然后才写功能。

第 10 篇那三组"最值钱的测试"(关键词表一致性 / 注册表与真实工具名逐字相同 / 定时任务不许吞异常)本质都是这个位置的东西------它们测的不是功能,是不变式

如果这一步前置,上面表格里那四个坑有三个会当场暴露

  • overlap ------ 一个"改参数看输出"的测试就能抓到
  • handle_error ------ 一个"断言节点可达"的测试就能抓到
  • skill_loader ------ 一个"断言调用点存在"的检查就能抓到

第三步:功能,且默认走"可回退"路径。

这一条项目其实做对了(AGENT_MODE),只是顺序晚了------是 Day 41 才做的。如果一开始就定"新链路一律可回退",前面那些重构会大胆很多。

第四步:可观测性,且跟功能同步做。

第 11 篇的路由模板化指标、request_id 贯穿日志,是 Day 33 才补的。而 Day 33 之前的所有问题排查,都靠"猜"。可观测性不该是"上线前补的",它应该跟功能一起长出来------因为它是你唯一能"看见"生产环境的眼睛。

一个诚实的自我评价

上面这套"先不变式、再验证、后功能"的顺序,说起来很漂亮。但我必须承认:在 26 天赶 42 天的进度里,我大概率还是会先写功能。

所以真正可执行的版本不是"调整顺序",而是加一条硬规则

每完成一个功能,问一句:"这个东西如果坏了,我怎么知道?" 答不上来的,就先别往下走。

这句话比"先写测试"实际得多------因为它在该问的时候 问,而不是在最没空的时候要求你重构。


六、可复用清单

  1. 可逆的决定快速做,不可逆的才需要论证。 上线前先想好怎么退回去,这条能省掉大量争论。
  2. 测试环境要"默认拒绝"。 respx 未匹配即报错,比十条"记得写 mock"的规定有用。
  3. LLM 是供应商,不是下属。 它的输出要当不可信输入验证;能抽成纯函数的约束,一定抽出来。
  4. "抽成常量"只保证容易一致,不保证一定一致。 一致性最终还得靠测试兜。
  5. 节点存在 ≠ 节点可达。 图、状态机、条件分支,都要有可达性断言。
  6. 配置项写对 ≠ 逻辑生效。 验证方法:调成 0,看输出变不变。
  7. 函数实现 ≠ 被调用。 写完全套能力后,grep 一遍调用点。
  8. 可观测性跟功能同步长,不要最后补。 否则之前所有排查都只能靠猜。
  9. 最有用的一句话:"这个东西如果坏了,我怎么知道?" 答不上来就别往下走------比"先写测试"更容易在赶工时执行。

七、全系列 14 篇索引

从 LangGraph 的状态循环写到这份复盘,整个系列到此收官。做个总目录,方便回看:

# 标题 主题
01 LangGraph Agent 状态循环与兜底 Agent 状态机、意图分类降级
02 MCP 实战:把工具层从 Agent 里解耦 MCP 协议、stdio 传输
03 contextvars 请求级用户隔离 多租户上下文透传
04 pgvector RAG:分块、维度与余弦检索 向量检索基础
05 多租户 RAG:让每个用户只检索自己的知识库 私有知识库隔离
06 删掉 if-else:把决策逻辑写进 System Prompt 提示词工程
07 SSE 流式对话与多轮记忆 流式协议、半包处理
08 搜索增强与正文提取 Tavily/DDG、正文抓取实测
09 四层降级与 Celery 异步陷阱 降级链路、异步任务
10 我改了 3 行关键词,测试立刻红了 测试体系、架构数据化对比
11 别再手动上线了:一条命令带备份、健康检查和自动回滚 部署与可观测性
12 迁移脚本能跑通,不代表你回滚得回来 Alembic + 非标准 DDL
13 我调了三个月 overlap=50,它其实一次都没生效 Skill / 知识库 / 工具边界
14 42 天 71 次提交之后,我重新看了一遍自己的架构决策 本篇 · 复盘

系列写完了,但项目还在更新。如果你在这 14 篇里找到对你有用的东西,欢迎去仓库对照真实代码------这也是我一直坚持每篇都放源码地址的原因


参考资料

  • Ticnix/weather-travel-recommend-system ------ 项目源码(仍在更新中)
  • 本项目 git log ------ 本文时间线与提交数据的来源(71 次非 merge 提交,Day 1 ~ Day 42)
  • 本项目 backend/app/services/agent.py ------ 图结构与 handle_error 可达性分析
  • 本项目 backend/app/services/knowledge_loader.py ------ split_textoverlap 参数(第 13 篇实测对象)
  • 本项目 backend/tests/conftest.py ------ 三条离线保障
  • 本系列第 09 篇 ------ handle_error 不可达的完整分析
  • 本系列第 10 篇 ------ 测试体系与"断言编排,不断言模型输出"
  • 本系列第 13 篇 ------ overlap 静默失效的决定性实验
  • Martin Fowler, Refactoring ------ "不变式"与"可逆决策"的思路来源
相关推荐
Ticnix1 小时前
我调了三个月 overlap=50,它其实一次都没生效
后端·python·agent
不好听6131 小时前
图数据库为什么查关系快:免索引邻接,以及怎么把小说抽成图——Graph RAG 系列之二
agent
李溪白1 小时前
篇五:RAG —— 让 Agent 拥有外挂知识库
agent
辉夜技术1 小时前
大模型的理解力,用户的解空间:Jev 如何填满一个空白象限
agent
DanCheng-studio1 小时前
毕设项目分享 大数据B站数据分析可视化系统
python·毕业设计·毕设
小林ixn1 小时前
从 LangChain 到 LangGraph:用「网状工作流」解锁多 Agent 协作的正确姿势
langchain·llm·agent
武子康1 小时前
自己做一个 Mini Reviewer:让 AI 审到本次准备提交的代码
人工智能·llm·agent
外收内放1 小时前
数据分析(jupyter与pandas初体验)
python·学习
花间相见2 小时前
【计算基础|网络04】—— HTTP接口实战(下):接口测试、鉴权与跨域排错
java·linux·人工智能·后端·python·计算机网络·postman