先说一个我们上周真实遇到的场景。
后台一个文件下载列表,客户每点一次下载,那条记录的「更新时间」就跟着变一次。这本身已经够奇怪了——下载是读取行为,不该改动记录的时间。更离谱的是,改完之后显示出来的时间还比实际早整整八个小时。
排查下来只有两条结论:
- 那张表的 DDL 里,
updated_at这一列写着ON UPDATE CURRENT_TIMESTAMP; - 数据库驱动在解析
DATETIME时按连接时区把它转成了时间对象,我们的格式化代码又做了一次 UTC 转换。
这两条事实,在任何一份需求文档里都不存在。在业务代码里也看不出来——那行 UPDATE 语句里只写了 download_count = download_count + 1。类型定义是对的,编译是过的,单元测试是绿的,接口返回 200。它只在一件事上暴露:真的有人点了下载,并且真的有人去看了那一列的时间。
我们团队前不久写过一篇《AI Agent + MCP:如何让 AI 真正理解一个大型软件项目?》,把那套让 AI 理解大型项目的知识体系拆成了五层。这篇文章是它的续篇:那五层都对,但在真实的生产环境里,我们还缺一层。
一、先把那五层复述一遍,我们完全同意
那篇文章的核心是一张分层清单。这里先复述一遍,因为它构成了我们下面讨论的地基:
| 层 | 回答的问题 | 主要载体 |
|---|---|---|
| ① 项目结构理解 | 这个项目有哪些模块、怎么分层、依赖怎么走 | 目录树、构建配置 |
| ② 代码语义理解 | 某个函数到底在做什么、被谁调用 | 源码、符号、调用图 |
| ③ 业务知识理解 | 为什么必须这么写、这条规则从哪来 | 需求文档、领域词表 |
| ④ 数据结构理解 | 数据长什么样、表与表怎么关联 | Schema、实体定义 |
| ⑤ 运行状态理解 | 现在跑得怎么样、哪里慢、哪里在报错 | 日志、指标、调用链 |
这五层有一个共同点,也是它们能被系统性建设起来的原因:它们都是「读得出来的知识」。 你去读目录、读代码、读文档、读表结构、读监控,每一层都有明确的载体,都能被索引、被检索、被喂给 Agent。
过去两年,我们给客户做 AI 落地,绝大部分工作确实就是在把这五层喂进去。效果也很好——Agent 知道自己该改哪个文件、这个函数被谁调用、这张表跟那张表什么关系,就已经比"盲改"强出一个量级。
但我们踩的坑,几乎都不在这五层里。
二、五层之上还有一层:隐性契约层
我们内部给这一层起了个名字,叫隐性契约层(Implicit Contract Layer)。
定义很简单:它指一个系统对外承诺、或对内依赖的,但没有被任何一层显式表达出来的行为约定。
它很容易和第五层「运行状态理解」混淆,但两者不是一回事:第五层回答的是"现在发生了什么",隐性契约层回答的是"什么条件下会发生什么,而这件事没人告诉过你"。日志会告诉你 500 了,但日志不会告诉你"这是因为你上一次构建被中断了"。
隐性契约的三个特征
| 特征 | 说明 | 后果 |
|---|---|---|
| 写在"不该写东西的地方" | 数据库某一列的列属性里、驱动的一行默认参数里、构建工具的产物命名规则里、CSS 的一个 fallback 值里 | 用常规的"读代码"路径找不到它 |
| 只在组合时才生效 | 单独看 A 没问题,单独看 B 没问题,A 和 B 放在一起就出事 | 单元测试和类型检查天然测不到 |
| 违反它的代价不落在编译期 | 编译通过、测试通过、CI 绿、接口 200,然后生产上才炸 | 反馈闭环极长,定位成本极高 |
五层与第六层的犯错成本对照
| 五层(显性知识) | 第六层(隐性契约) | |
|---|---|---|
| 错误何时暴露 | 编译期 / 测试期 | 生产期 |
| 谁来兜底 | 编译器、类型、单测 | 没有人兜底 |
| 修复成本 | 分钟级 | 小时级,且往往伴随一次事故 |
| Agent 的典型表现 | 改错了,但立刻报错 | 改得"看起来完全正确",然后安静地错 |
最后一行是我们要讨论的重点。第六层不是"更难的第五层",它是 Agent 输出可信度最容易崩掉的地方。
三、五个真实案例
下面五个案例全部来自我们自己的生产环境,都是今年的事。我把它们原样放上来,因为它们共同解释了一件事:为什么"读一遍"永远不够。
案例一:ON UPDATE CURRENT_TIMESTAMP 是行级语义,不是列级语义
这是开头那个场景的第一层根因。
那张下载记录表的字段定义大致是这样:
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
我们的后端代码做了一件看起来再正当不过的事——统计下载次数:
// 直觉写法:只加计数,不动时间
await executeResult(
'UPDATE download_files SET download_count = download_count + 1 WHERE id = ?',
[id],
);
直觉上,这条语句只改了 download_count,updated_at 没出现,那它就不该变。但 MySQL 的 ON UPDATE CURRENT_TIMESTAMP 是行级触发器语义:只要这一行被 UPDATE 语句命中,无论你改了哪一列,时间戳都会被刷新。
正确写法是显式把该列"赋值给它自己":
// MySQL 规定:显式赋值的列不触发自动更新。
await executeResult(
'UPDATE download_files SET download_count = download_count + 1, updated_at = updated_at WHERE id = ?',
[id],
);
真实库上的对照实验(同一行,同一条件):
| 写法 | download_count | updated_at 是否变动 |
|---|---|---|
SET download_count = download_count + 1 | +1 | 被改写成当前时间 |
SET download_count = download_count + 1, updated_at = updated_at | +1 | 保持不变 ✓ |
这层契约在哪里写着?在 MySQL 手册的一行注脚里。它不在需求文档里,不在实体定义里,也不在任何 TypeScript 类型里。Agent 读遍整个仓库,也读不到这一条。
案例二:驱动默认时区——那八个小时是怎么丢的
同一个场景的第二层根因。
我们在真实库上跑了一条探针:
SELECT @@session.time_zone; -- SYSTEM(实测等价于 +08:00)
SELECT NOW();
结果很关键:连接会话的时区是 SYSTEM,也就是 +08:00。而数据库驱动在把 DATETIME 解析成时间对象时,按连接时区来解释这个"没有时区信息的时间"——这是合理的行为。问题出在下一步:我们的格式化代码调用了 toISOString(),那是一个强制转 UTC 的方法。
于是链路变成了:
库里 15:53(无时区)
→ 驱动按 +08:00 解析为 15:53+08:00
→ toISOString() 转UTC → 07:53Z
→ 前端按 07:53 显示
→ 用户看到比实际早 8 小时
修复方式是按本地时间分量手工拼接,而不是信任任何中间对象的时区语义:
const p = (n: number) => String(n).padStart(2, '0');
return `${value.getFullYear()}-${p(value.getMonth() + 1)}-${p(value.getDate())} ` +
`${p(value.getHours())}:${p(value.getMinutes())}`;
这里有一条更值得记住的工程原则:
涉及时间字段的对账,永远以"渲染出来的字符串"为准,不要以中间对象为准。
因为时间对象携带的时区语义,取决于驱动版本、连接参数、运行时环境三者共同决定的一层约定——这层约定不在你的代码里。
案例三:构建产物的生命周期——源码完全正确,产物是坏的
这个案例最能说明"第六层"和"代码"的关系。
现象:改完代码、构建、部署。部分页面 500,服务端日志里是:
ERR_MODULE_NOT_FOUND: Cannot find module './chunks/sessions_abc123.mjs'
排查:源码没问题,git 状态干净,类型检查通过。真正的问题在上一次构建——那次构建因为文件被占用(EPERM)中途中断了,新的 chunk 生成了一半,入口文件已经指向了新 chunk 的哈希,但对应的 chunk 却没落盘。
根因:Astro/Node 的 SSR 产物里,入口文件与各个 chunk 之间靠文件名哈希维持一层强耦合契约。这个契约由构建工具持有,源码完全不知道它的存在。源码可以 100% 正确,产物依然是坏的。
处置:构建中断必须被当作"产物已损坏"处理——停服务、清空输出目录、重新完整构建。绝不能靠"再构建一次"去补,因为补构建不会清理上一次的半成品。
这一条里的隐性契约是:dist(或任何构建输出目录)不是一个可以增量信任的目录,它的正确性依赖于"上一次构建完整结束"这个外部事实。
案例四:自研组件库的隐式依赖——类名存在,不等于图标存在
这个案例最隐蔽,因为它连报错都没有。
现象:页面上两个图标显示为实心色块。CSS 类写对了,DOM 结构正确,控制台一条报错都没有。
根因:这个项目用的是自研的 SVG mask 图标方案——每个图标类对应一个 --bi-svg 数据 URI,通过 mask-image 渲染。类名不存在时,元素上根本挂不上样式,看起来是"没写对";但类名存在、而对应的 SVG 数据 URI 没定义时,mask-image 会回退成 none——mask 失效,元素直接露出自身背景色,于是渲染成一个 1em 见方的实心块。
| 状态 | DOM 中的类名 | CSS 中的定义 | 渲染结果 |
|---|---|---|---|
| 正常 | 有 | 有 | 正确图标 |
| 类名写错 | 错的名字 | 无 | 无样式(肉眼可见的"没生效") |
| 定义缺失 | 有 | 无 | 1em 实心色块(最迷惑) |
关键结论:"类名存在"和"图标存在"是两件事,而 HTML 源码里看不出任何区别。 这份契约的载体是"某个 CSS 文件里必须补齐一条数据 URI"——它既不在组件代码里,也不在类型系统里,只有当你打开页面看一眼才会发现。
这个案例还叠加了第二重隐性契约:那次线上"改了但没生效",真实原因是本地改了但没提交、没部署。浏览器与服务端之间的缓存时机,同样是契约的一部分。
案例五(短):那两条 CDN 规则的顺序
同一天我们还撞上了一个更典型的"验证方式本身有副作用"的例子。
签名直链在浏览器地址栏里手动打开——正常播放。放进页面的 <video> 标签里——403。
两条隐性契约:
- 对象存储对私有下载凭证的签名,必须覆盖包含
?e=过期参数的完整 URL,签名串里多一个换行符都会导致校验失败; - 浏览器加载视频子资源时必然携带
Referer,而防盗链白名单里如果没有主域名,边缘节点会直接拦掉。
第二条的教训值得单独拎出来:
"手动访问能通",不等于"页面里能通"。 因为"手动访问"这个动作本身不产生 Referer。
这就是隐性契约层最讨厌的地方——你用来验证它的手段,本身会改变验证结果。
四、为什么 Agent 在这一层特别容易翻车
把上面五个案例放一起,能看出四个结构性的原因。
第一,代价不对称。 五层的错误会被编译器、类型检查和单元测试挡住,Agent 错了立刻就知道。第六层的错误不会——它会以一段"看起来完全正确"的改动通过所有人眼审查,然后在生产上炸。Agent 的自信幻觉在五层里代价可控,在第六层里代价是全量。
第二,运行期事实不在仓库里。 @@session.time_zone 的值、构建工具对中断的处理方式、CDN 边缘的防风控规则、CSS 里的回退值——这些都不在代码仓库中。Agent 再能读代码,也读不到它们。
第三,现有 MCP 工具的能力边界卡在这里。 那篇文章里提到「运行状态理解」这一层,实际接入的工具通常覆盖的是进程、日志、指标、链路。这些工具能告诉你"500 了"、"慢了"、"CPU 高了",但没有任何一个工具会告诉你"这次的 500 是因为上一次构建被中断"。前者是观测数据,后者是因果知识——两者之间隔着的,正是隐性契约层。
第四,Agent 会把"语法正确"当成"语义正确"。 列名对、类型对、SQL 合法、API 用法标准——这些全对,Agent 就会给出一个非常笃定的结论。而真正的错误,藏在那一列多出来的 ON UPDATE 上。
五、把第六层显性化的四种做法
这一层不可能被"喂"进去,但可以被"建"出来。我们自己在项目里做的是下面四件事,成本从低到高。
做法一:把契约写进它所属的那一层(成本最低)
遇到这几类东西,写一行注释:
- DDL 里带
ON UPDATE、隐式类型转换、时区敏感的列; - 驱动的非默认连接参数;
- 构建/部署流程里"必须按顺序做"的动作;
- 自研组件库里"定义了才生效"的东西。
注释的位置比注释的内容更重要: 一定要写在能被 grep 到的地方。写在群里、写在邮件里、写在某个人的脑子里,等于不存在。
做法二:给 Agent 可执行的探针,而不是给人看的文档
一条能跑的 SQL,胜过一段描述。上面案例二就是这么定位的:
SELECT @@session.time_zone, NOW();
SHOW COLUMNS FROM download_files LIKE 'updated_at';
判断一份材料对 Agent 有没有用,标准只有一条:
这段信息能不能被 Agent 当场执行并拿到结果。
不能执行的文档,Agent 只能"读";能执行的探针,Agent 能"验证"。
这一条是我们这两年最实在的体会——给 Agent 工具,比为 Agent 写文档有用得多。
做法三:把环境事实放进项目的入口约定文件
我们在项目根目录放一份专门给 Agent 看的约定,写清这些环境级事实:
| 写什么 | 为什么 |
|---|---|
| 构建前必须先清空输出目录 | 防止案例三的中断残留 |
| 数据库时区为 +08:00,驱动按 SYSTEM 解析 | 防止案例二的时间偏移 |
| 时间字段对账以渲染字符串为准 | 同上 |
| 组件库图标必须有 data-URI 定义,类名存在不等于图标存在 | 防止案例四的实心块 |
| 私有资源的鉴权规则与防盗链白名单 | 防止案例五的 403 |
关键不是"写得全",而是每一条后面都跟一个可执行的自检动作。写成"注意时区问题"没用,写成"跑这句 SQL 确认 @@session.time_zone = SYSTEM"才有用。
做法四:给第六层配一套专属的验证方式
我们的分层验证规则是这样的:
| 改了什么 | 五层的验证 | 第六层的验证 |
|---|---|---|
| 业务逻辑 | 类型检查 + 单元测试 | — |
| 构建/依赖/部署 | 构建通过 | 清空产物 → 从零完整构建 → 起服务 → 打关键页面 |
| 数据/时间/计数 | 接口 200 | 以渲染出来的字符串为准做一次对账 |
| 前端静态资源 | 构建通过 | 真的打开页面看一眼 |
一句话总结:
第六层的验证方式,必须是"真的跑一遍",不能是"读一遍"。
六、我们自己的做法
这套项目里,上面四件事已经沉淀成了几个固定动作:
- 数据库相关的改动,先跑探针脚本确认环境事实(会话时区、列属性、索引),再动代码;
- 构建流程里"清空输出目录"是硬步骤,不是可选项;
- 时间字段的显示,一律以渲染结果与库内数据逐条对账;
- 前端静态资源(图标、字体、图片)改动后,必须在真实页面里目视确认;
- 给 Agent 的约定集中在入口文件里,每条都带一个可执行的确认动作。
这些做法本身一点都不高级。它们唯一的价值是:把原本只存在于某个工程师记忆里的东西,搬到了一个 Agent 也能读到、也能执行的位置。
七、中国企业的三个现实约束
我们服务的客户大多在政企、制造业、科研院所。在这个环境里补第六层,有三个绕不开的约束。
第一,内网与离线。 Agent 拿不到外网文档,很多框架级的知识无法实时检索。所以探针必须能离线跑——可执行的探针比联网查文档更适合内网场景,这是离线环境反而更该做的事。
第二,老系统里隐性契约最多。 接手一套三年前的系统,源码可能还在,文档基本没有,当事人已经离职。这时候最有价值的交付物不是"功能清单",而是**"契约清单"**——哪些列会自动更新、哪些接口有隐式副作用、哪些配置改了会连带出事、构建和部署必须按什么顺序来。
第三,生产环境权限最小化。 探针要设计成只读、有节流、可随时停。不要为了取证去要一份高权限账号,那样得到的证据不值得。
八、速查卡
隐性契约层自检清单
| 检查项 | 具体动作 | 命中时怎么写下来 |
|---|---|---|
| 哪些列会自动变化? | SHOW COLUMNS 找 ON UPDATE / 默认值 / 生成列 | 在迁移脚本里注释该列的更新语义 |
| 时间对不对? | 跑 SELECT @@session.time_zone, NOW(),再和页面渲染值逐条对账 | 把时区与格式化规则写进入口约定 |
| 构建中断过吗? | 检查输出目录是否完整、入口引用的 chunk 是否都在 | 流程里固化"清空后完整重建" |
| 组件库有没有"不定义就静默降级"的东西? | 图标、字体、变量,改完打开页面目视确认 | 在组件库说明里写明"类名 ≠ 已定义" |
| 资源访问有没有隐式规则? | 签名覆盖范围、防盗链白名单、Referer 行为 | 记下"手动访问能通 ≠ 页面里能通" |
| 这个坑,除了我还有人知道吗? | 它现在存在于哪里:群里 / 邮件 / 脑子里 / 代码注释里 | 移到能被 grep 到的地方 |
一句话版
五层读得出来,第六层读不出来。
第六层的验证只有一种:真的跑一遍。
凡是真的跑过才知道的事,立刻写进能被 grep 到的地方。
九、金兰信息的立场
我们不会说"Agent 已经能理解一个大型项目了",那是把话说满了;我们也不会说"隐性契约层只能靠老师傅的经验",那是把话说死了。
我们的判断是:在 AI 参与研发的今天,一个团队真正的技术资产,正在从"代码和文档"转移到"把经验表达成可执行形式的能力"上。
- 那五层显性知识,正在快速商品化——任何团队接上 Agent 都能读。
- 隐性契约层,短期内不会被自动化替代,因为它散落在列属性、驱动参数、构建产物、边缘规则里,它的载体是"经历过一次"。
- 所以真正拉开差距的动作只有一个:把经历过的那一次,变成一个能被执行、能被检索、能被 Agent 调用的东西。
这件事不需要等工具,也不需要等模型变强。你现在就可以做:挑一条你知道但团队里没有写下来的规则,把它写进代码注释、写成一条能跑的探针、或者放进给 Agent 的约定文件里。
金兰信息在成都做定制开发、系统集成和 AI 落地服务。我们自己的团队现在就在做这件事——把项目里那些"只有踩过才知道"的东西,一条一条搬进 Agent 能读、能跑的位置。如果你正在考虑三件事——把团队沉淀的经验做成 AI 能调用的能力、接手一套没有文档的老系统需要一次可核查的现状评估、或者只是想让 AI 在研发流程里真正帮上忙而不是停在写周报——欢迎找我们聊聊。
本文基于我们近期的真实生产排障记录整理。文中涉及的数据库行为、驱动时区与构建产物特性,均与具体版本和部署环境相关,请以你自己环境中的实测结果为准;文中出现的 SQL 仅供只读诊断参考,请勿在生产环境执行任何写操作。
