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 篇都在讲"某个点怎么做的"。这一篇反过来,讲这些点连起来之后,我看到了什么。
- 一个真实的时间尺度 :
git log数出来 71 次提交、Day 1 → Day 42、2026-08-23 到 2026-09-17,实际日历跨度 26 天。这不是"做了三个月"的项目,很多决策是在赶进度时做的------而赶进度时做的决策,事后回看质量两极分化明显。 - "事后觉得对"的决策有一个共同特征 :它们都是把不确定性关进笼子------一键回退、离线测试、纯函数审计、可切换模式。共同点不是"技术上更先进",而是"出问题时我有退路"。
- "现在想改"的决策也有共同特征 :它们都是为了写得快而留下的隐含约定 ------
handle_error不可达、overlap静默失效、skill_loader没有调用方、两条建表路径。全部是"声明了但没被验证"。 - 重写的话,优先级会这么排 :先定不变式 (什么东西永远不许破坏),再写验证(怎么知道它没被破坏),最后才写功能。这个项目的顺序恰好是反的。
目录
- 一、先把数字摆出来
- 二、事后觉得对的:四个"把不确定性关进笼子"的决策
- 三、现在想改的:四个"声明了但没被验证"的坑
- 四、两类决策的分界线在哪
- 五、重写一遍,我会怎么排优先级
- 六、可复用清单
- [七、全系列 14 篇索引](#七、全系列 14 篇索引 "#%E4%B8%83%E5%85%A8%E7%B3%BB%E5%88%97-14-%E7%AF%87%E7%B4%A2%E5%BC%95")
一、先把数字摆出来
不凭印象说话,先量一遍。以下都是脚本实际统计的(排除 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_text 的 overlap 从未生效 |
配置项 + 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 发现的那类问题。
五、重写一遍,我会怎么排优先级
如果重来一次,我会把顺序调成这样。注意这不是"功能优先级",而是**"什么先做"的优先级**。
第一步:先写"不变式清单",不写功能。
在写第一行业务代码之前,先列清楚"什么东西永远不许破坏"。比如这个项目现在能总结出的几条:
- 用了
asyncio.run()/ 独立事件循环的地方,不许有连接池缓存 (该坑已三次现身:Celery / 测试 conftest / alembic,一律NullPool)。 - MCP 工具名在三个地方(
server.py、注册表、测试)必须逐字一致。 - 有用户态的数据检索,
WHERE user_id = :user_id不许省。 - 外部服务调用必须有降级路径,且降级路径必须真的能返回东西(第 08 篇的 DDG 兜底就是"形式上降级"的反例)。
这四条不是我事后编的,是这 26 天里踩出来的。但踩出来才总结,代价就是每条都真的坑过一次。
第二步:每条不变式配一个测试,然后才写功能。
第 10 篇那三组"最值钱的测试"(关键词表一致性 / 注册表与真实工具名逐字相同 / 定时任务不许吞异常)本质都是这个位置的东西------它们测的不是功能,是不变式。
如果这一步前置,上面表格里那四个坑有三个会当场暴露:
overlap------ 一个"改参数看输出"的测试就能抓到handle_error------ 一个"断言节点可达"的测试就能抓到skill_loader------ 一个"断言调用点存在"的检查就能抓到
第三步:功能,且默认走"可回退"路径。
这一条项目其实做对了(AGENT_MODE),只是顺序晚了------是 Day 41 才做的。如果一开始就定"新链路一律可回退",前面那些重构会大胆很多。
第四步:可观测性,且跟功能同步做。
第 11 篇的路由模板化指标、request_id 贯穿日志,是 Day 33 才补的。而 Day 33 之前的所有问题排查,都靠"猜"。可观测性不该是"上线前补的",它应该跟功能一起长出来------因为它是你唯一能"看见"生产环境的眼睛。
一个诚实的自我评价
上面这套"先不变式、再验证、后功能"的顺序,说起来很漂亮。但我必须承认:在 26 天赶 42 天的进度里,我大概率还是会先写功能。
所以真正可执行的版本不是"调整顺序",而是加一条硬规则:
每完成一个功能,问一句:"这个东西如果坏了,我怎么知道?" 答不上来的,就先别往下走。
这句话比"先写测试"实际得多------因为它在该问的时候 问,而不是在最没空的时候要求你重构。
六、可复用清单
- 可逆的决定快速做,不可逆的才需要论证。 上线前先想好怎么退回去,这条能省掉大量争论。
- 测试环境要"默认拒绝"。
respx未匹配即报错,比十条"记得写 mock"的规定有用。 - LLM 是供应商,不是下属。 它的输出要当不可信输入验证;能抽成纯函数的约束,一定抽出来。
- "抽成常量"只保证容易一致,不保证一定一致。 一致性最终还得靠测试兜。
- 节点存在 ≠ 节点可达。 图、状态机、条件分支,都要有可达性断言。
- 配置项写对 ≠ 逻辑生效。 验证方法:调成 0,看输出变不变。
- 函数实现 ≠ 被调用。 写完全套能力后,grep 一遍调用点。
- 可观测性跟功能同步长,不要最后补。 否则之前所有排查都只能靠猜。
- 最有用的一句话:"这个东西如果坏了,我怎么知道?" 答不上来就别往下走------比"先写测试"更容易在赶工时执行。
七、全系列 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_text的overlap参数(第 13 篇实测对象) - 本项目
backend/tests/conftest.py------ 三条离线保障 - 本系列第 09 篇 ------
handle_error不可达的完整分析 - 本系列第 10 篇 ------ 测试体系与"断言编排,不断言模型输出"
- 本系列第 13 篇 ------
overlap静默失效的决定性实验 - Martin Fowler, Refactoring ------ "不变式"与"可逆决策"的思路来源