「信息不完整」不是它笨,是常量——有些事根本写不进提示词。 一个三十行的模块,能数出十几处「代码里没写、但必须有答案」的决定。 这些空缺它不会停下来问,它会按最常见的那种填——填错了也不告诉你。
上一篇说四个依赖凑齐就能干活。这一篇说的是:凑齐了也不够。
因为信息的缺口不在「给多少」上,在「有些信息根本没法给」上。
「除零应该返回什么」这种事,你心里有答案,但你从来没写下来过——
因为你写代码的时候根本不需要写,你自己知道。
这一篇的 Demo 从一个「看起来毫无歧义」的函数开始:def divide(a, b): return a / b。
然后列出四个它没法回答的问题,真跑四种行为,看差多远。
最后数一数一个三十行的真模块里,有多少处这种空缺。
一、你看得见的和它看得见的
同一个函数,你看到的和它看到的不是一回事。
你看到 divide(6, 0) 的时候,脑子里立刻浮现出「这是我们上周讨论过的那件事,当时决定返回 0」——
这段记忆不在代码里,也不在任何文件里。
它看到的是:一个函数,两个参数,一个除号。
没有历史、没有讨论、没有「当时决定」。
它只能按公开世界的惯例猜:除法除零会抛异常,那就让它抛。
这个差距不是「它不够聪明」能补上的。
再聪明的人拿到这一行代码,也得问一句「除零怎么办」。
区别在于:人会问,它大概率不问。
为什么它不问?因为「问」这个动作在训练数据里是少数。
大部分代码片段是「直接写出来的」,不是「先问三个问题再写」。
它学的是那个分布,所以默认行为就是「直接写」。
二、隐性知识写不进提示词
「隐性知识」是那种「你会的,但你说不出来」的东西。 比如:这个模块的日志要写英文、那个模块要写中文;错误码必须从 1000 起;时间统一存 UTC。 这些东西有三个共同点: ① 它们存在于老人的脑子里;② 新人踩过坑才知道;③ 从来没人写下来过。 这三点合起来意味着:它一定不知道。
| 隐性知识 | 为什么写不下来 | 它踩的坑 |
|---|---|---|
| 错误码从 1000 起 | 「一直就这样」,没人记得为什么 | 从 1 开始编 |
| 日志用英文 | 「团队习惯」,没写进规范 | 写中文日志 |
| 时间存 UTC | 「踩过坑才知道」,坑没记下来 | 存本地时间 |
| 这个接口不能重试 | 「下游扛不住」,只有当事人在 | 加了重试循环 |
这张表最右边那一列,就是「信息不完整」的具体价格。 每一条都是「它做对了语法、做错了约定」。 而这种错最难查,因为它不报错。
三、补全信息的三条通道
既然信息不完整是常量,那能做的就是「尽量补」,补不上的「做成可验证」。
三条通道,按性价比排:
通道一:写进文件。
AGENTS.md、README、代码注释、类型注解——这四样是「一次写、永久生效」的。
第 6 章整章讲这个。
通道二:用测试代替说明。
「除零返回 0」这句话写起来要解释半天,写成一条测试用例就是一行:assert divide(6, 0) == 0。
而且它能自己跑。
测试是「可执行的规格」——这句话在 10.3 还会再讲一遍。
通道三:让它先复述,你再看。
动手之前让它说一遍「我理解的任务是……」,理解歪了你当场纠正,比做完再改便宜得多。
这一条不需要任何工具,但很多人不用——因为觉得「多一步麻烦」。
麻烦一步,省一轮。
三条通道有个共同点:都不靠「说清楚」,靠「写下来」或「跑一遍」。
因为说清楚这件事,人做不到——你说得再细,也有你没想到的。
四、动手 Demo:代码里没写的那些决定,都是边界
这个 Demo 从一行代码开始:def divide(a, b): return a / b。
这行代码看起来毫无歧义——除嘛,还能有什么歧义?
然后列出四个问题,逐个真跑:除零、保留位数、负数、字符串参数。
四种结果里,只有一种是你「算出来的」,另外三种是「撞出来的」。
最后换一个三十行的真模块,数一数里面有多少处「没写下来的决定」。
def divide(a, b):
return a / b
# 四个没人回答过的问题
cases = [("b=0", lambda: divide(6, 0)),
("保留两位", lambda: round(divide(10, 3), 2)),
("负数", lambda: divide(-6, 3)),
("字符串", lambda: divide("6", 2))]
for name, fn in cases:
try:
print(pad(name, 10) + "-> %r" % (fn(),))
except Exception as e:
print(pad(name, 10) + "-> %s: %s" % (type(e).__name__, e))
实跑输出:
--------------------------------------------------------------------
代码里没写的那些决定,都是边界
--------------------------------------------------------------------
① 一个看起来没有歧义的函数
--------------------------------------------------------------------
函数体就一行:return a / b。
读代码的人会觉得「这没什么可问的」。真做起来,至少四个问题没有答案。
② 四个问题,代码里一个字都没有
--------------------------------------------------------------------
| 问题 | 可能的答案 | 代码里有答案吗 |
|---|---|:---:|
| b 等于 0 怎么办 | 抛异常/返回 0/返回 None/返回无穷 | 没有 |
| 结果保留几位 | 原样/两位小数/四舍五入到整数 | 没有 |
| a 是负数怎么算 | 正常算/按绝对值算/报错 | 没有 |
| a、b 是字符串怎么办 | 报错/先转数字/不处理 | 没有 |
这四件事,每一种答案在不同的业务里都合理。代码只写了「除」这个动作,没写「在什么情况下不除」。
③ 真跑四种行为,看差多远
--------------------------------------------------------------------
b=0 -> ZeroDivisionError: division by zero
保留两位 -> 3.33
负数 -> -2.0
字符串 -> TypeError: unsupported operand type(s) for /: 'str' and 'int'
四种结果里,只有一种(10/3 保留两位)是「算出来的」,另外三种都是「撞出来的」——撞到异常、
撞到浮点、撞到类型错误。
④ 这种事在一个真模块里有几个
--------------------------------------------------------------------
| 隐式决策 | 个数 | 举例 |
|---:|---:|---|
| 默认值 | 5 | encoding="utf-8"、default=None、step=1 |
| 保护性判断 | 1 | if not os.path.exists(path) |
| 魔法数字 | 4 | 字典下标 0、默认累加 1 |
| 编码约定 | 4 | 到处都写 utf-8,但没写在文档里 |
这个模块 30 行,能数出这么多「没写在文档里的决定」。
每一条都是「它必须猜、你从没告诉过它」的地方。
⑤ 边界的意思:不是它笨,是信息不在
--------------------------------------------------------------------
「信息永远不完整」不是抱怨,是常量。
你能做的只有两件事:把能说的说全,把说不全的地方做成「可验证」。
! 可验证的意思是:别问「除零该返回什么」,直接给一条测试用例——测试用例本身就是答案,
而且它能自己跑。
四个问题的答案,代码里一个字都没有。
除零抛 ZeroDivisionError、10/3 得到 3.3333333333333335、负数得到 -2.0、
字符串报 TypeError。
这四种都是「默认行为」,不是「设计决定」。
注意「保留两位」那一行:3.33 是我在外面套了个 round 才得到的。
函数本身给的是 16 位小数——如果这个数是钱,那这个函数就是错的。
但它没错,它只是没被要求。
第四节换了个三十行的模块,数出四类隐式决策:默认值 5 处、保护性判断 1 处、魔法数字 4 处、
编码约定 4 处。
这些数字不是我编的,是扫出来的——
扫的方式很粗糙(数等号右边、数 if not、数数字),但足够说明「这种空缺有多密」。
最后那句「可验证」是这一篇的解法:
别问「除零该返回什么」,直接给一条测试用例。
测试用例本身就是答案,而且它能自己跑。
说不清的,就写成能跑的。
30 秒实验
- 挑一个你写的函数,试着列「它没回答的问题」。 列到第三个的时候你会停一下——那个你答不上来的,就是你一直靠直觉在填的地方。
- 把其中一条落成测试用例。 一条就够,感受一下「写测试比写文档快」这件事。
- 想一想你团队里最资深的那个人,他脑子里有多少没写下来的东西。 他休假两周,项目会慢多少? 这个数字就是「隐性知识」的价格。
常见误区
- 「说清楚就行了,说不清是表达问题。」 有些事不是表达问题,是「从来没人想过要说」。 除零返回什么,你写代码的时候根本没考虑过,因为你自己知道。 这种「自己知道所以没写」的东西,是最难补的。
- 「它会自己判断,不用我操心。」 它的判断依据是「公开世界的惯例」,不是「你们团队的惯例」。 这两者经常不一样,而且不一样的时候它不会提醒你。
- 「信息不完整,那就别用它了。」 所有协作都是信息不完整的——你给同事派活,他也不知道你脑子里的全部。 区别在于同事会问,所以你要做的是「让它问」或者「替它答」。
自测三问(点开对答案)
1. 为什么「信息不完整」是常量,不是缺陷? —— **因为有些事根本写不进提示词**——「当时决定返回 0」这种记忆,不在代码里也不在任何文件里。 **你写代码的时候不需要写,因为你自己知道;而它不知道。** 2. 补全信息的三条通道,哪一条最省事? —— **用测试代替说明。** 「除零返回 0」写起来要解释半天, 写成 `assert divide(6, 0) == 0` 就是一行,**而且它能自己跑、能自己验**。 3. 它为什么大概率不会主动问你? —— **因为「问」这个动作在训练数据里是少数。** **大部分代码片段是直接写出来的,不是先问三个问题再写。** **它学的是那个分布,默认行为就是「直接写」。**下一篇 2.4 讲第二条边界: 就算信息给全了,它还是会犯三类工程错误——记错事实、改多范围、断掉一致性。
- ☐ (本篇完,下一篇 2.4 第二条边界:Codex 会犯三类工程错误)