小叶让AI写了一段统计报名人数的程序。第一次运行,屏幕出现一串英文错误;换了一个版本,英文错误消失了,结果却比人工核对的人数少。小叶继续要求“把错误修好”,AI又交来一段更长的代码。几轮下来,文件越来越多,小叶仍说不清:到底是哪一步错了,这一版为什么值得相信。

这时真正需要的能力,是把一个看似模糊的“程序不对”,变成能够观察和判断的具体问题。程序阅读帮助我们理解代码准备怎样完成任务;调试则利用实际运行的证据,寻找偏差怎样产生,并核对修改是否解决了它。两者结合,才能让我们对AI提供的方案有自己的判断。

本章围绕一个本地报名汇总任务展开。所有活动、记录和人数都是虚构教学数据,程序只在本机读取给定列表、计算并显示结果。我们先处理一个会报错的版本,再处理一个能够正常结束却统计错误的版本。通过这两条路线,学习怎样看调用过程、读报错、保存复现条件、观察中间状态、检验原因,以及让AI参与有证据的修改。

一、先知道什么结果才算对,才能看出程序偏在哪里

小叶手里的原始资料如下。一条记录表示一次报名,报名人可以同时为几位同学报名,所以“记录条数”和“报名人数”是两种数量。

报名记录编号 活动编号 当前状态 这次报名的人数
R001 001 confirmed,已确认 2
R002 001 confirmed,已确认 1
R003 002 cancelled,已取消 4
R004 002 confirmed,已确认 3

这次任务是:只统计已确认的报名,按活动分别累计人数。活动001有两次已确认报名,合计3人;活动002那次4人的报名已经取消,只计另一笔3人。因此,期望得到的结果是{"001": 3, "002": 3}。字典的键是活动编号,值是该活动已确认报名的人数。本章只在结果中列出存在已确认记录的活动;没有已确认记录的活动不另补一项零。

这个期望结果来自任务含义和输入资料,不来自对AI代码的模仿。如果先照着程序的算法算一遍,再拿自己的答案与程序比较,两者可能共同重复同一个错误。例如,程序每看到一条记录就加一,小叶也只数记录,两边一致,仍没有完成按人数统计的任务。

所以,读程序之前可以先问:资料中的每一行代表什么,哪些记录应进入统计,多条记录怎样合在一起,结果交给谁后应当被怎样理解。这些问题无需写成长篇需求文档,但要形成一份明确的判断依据。否则“修好了”很容易退化成“终于没报错了”。

在这个小样本中,人工算出每个活动的人数很容易。面对大文件,也可以先选一小部分能独立核对的资料,建立一个可信参照。这个参照暂时不能证明整个大文件都正确,却能帮助我们检验最基本的处理关系是否符合任务。

程序的实际行为也要用同样具体的语言记录。屏幕若显示001为1、002为3,可以先记录“这四条资料中,001比预期的3人少了2人,002与预期相符”;暂时还不能说“循环有问题”或“AI不会加法”。前一句是观察到的偏差,后两句已经开始猜原因。将两者分开,后面的排查才能有依据。

二、读程序先找入口,再沿数据走过函数

把下面代码保存为summary_missing.py。它故意保留一个字段读取错误,供我们观察报错。运行时在文件所在目录执行python summary_missing.py;这里的python指你已经用于前几章的Python解释器。

可复制模板
RECORDS = [
    {"record_id": "R001", "activity_id": "001", "status": "confirmed", "people": 2},
    {"record_id": "R002", "activity_id": "001", "status": "confirmed", "people": 1},
    {"record_id": "R003", "activity_id": "002", "status": "cancelled", "people": 4},
    {"record_id": "R004", "activity_id": "002", "status": "confirmed", "people": 3},
]


def read_people(row):
    return row["count"]


def summarize(records):
    totals = {}
    for row in records:
        if row["status"] == "confirmed":
            key = row["activity_id"]
            totals[key] = totals.get(key, 0) + read_people(row)
    return totals


def main():
    result = summarize(RECORDS)
    print(result)


if __name__ == "__main__":
    main()

阅读时不必立刻解释每个标点。先找这次实际运行从哪里进入业务处理。文件加载时建立RECORDS列表,并定义三个函数;直接运行文件时,最后的条件成立,执行main()。定义函数并不会立即把函数体里的统计做一遍,真正的处理由调用触发。

main把原始列表交给summarize,后者建立一个空字典,再逐条查看记录。状态为已确认时,取出活动编号,读取这笔报名的人数,更新该活动的小计。正常完成循环时,把字典返回给main,最后显示。我们先把代码大致读成了“哪些资料经过哪些动作到达结果”,再查看各动作是否做对。

这里还有一条函数调用关系:main调用summarize,summarize在处理已确认记录时调用read_people,后者收到的是当时那一条记录。它不是重新读取整个报名表,也不是自动知道上一次报名的人数。参数row的来源,可以顺着调用处找到。

报名汇总的调用与数据关系
图 C09-01|报名汇总的调用与数据关系

图中箭头表示调用时交出资料、返回时交回结果。函数写在文件的哪一段,和运行时按什么顺序进入它,是两个需要结合起来看的信息。小叶若只从第一行往下逐句翻译,很可能记住了三个定义,却没有发现最终执行依赖的是哪一次调用。

较长的AI程序也可以这样阅读:从启动入口或当前操作入口出发,找到接收输入、变换资料和交付结果的主要位置。暂时不需要展开与当前问题无关的每个辅助函数。如果结果在进入格式排版前已经错误,优先看产生数据的过程;如果内存里的结果正确而保存文件错误,再沿保存那条路径看下去。阅读范围随着证据缩小,比对整份代码平均用力更有效。

函数名称和注释能提供方向,但还要看实际执行语句。一个叫sum_confirmed_people的函数,仍可能只统计记录条数。我们读名字来形成预期,读输入、判断、更新和返回来确认它真正做了什么。

三、报错与算错,会给出不同的线索

程序出问题,至少有几种常见表现。第一种是代码的写法不能被解释器按语法理解,例如if后漏了冒号。第二种是语法成立,但运行到某个操作时发生异常,例如读取字典里不存在的键。第三种是所有操作都能执行,结果却违背任务要求,例如把累计人数写成只保留最后一笔人数。

这三种表现决定了初始线索从哪里来。语法错误通常会指出检测到问题的附近位置;执行中的异常会显示类型、说明和调用踪迹;没有报错的逻辑偏差则需要拿实际结果与任务参照比较。不能要求第三种情况也自动给出一条“这里统计错了”的报错。

Python官方教程区分语法错误与执行时的异常,并提醒:语法错误箭头指向的是检测位置,不一定就是应该修改的位置。例如缺冒号可能让后面的表达式成为被指出的位置。由报错定位到原因,仍要结合周围代码。参见Python错误与异常教程。

本章第一个程序属于第二种情况:读取row["count"]时,记录中没有这个键。Python不会因为people看起来也是人数,就自动把它当成count来使用。字符串键在程序里要按实际约定对应,名字相近或意思相近不等于同一个键。

还要区分“程序遇到异常”和“整项工作完全没有发生”。在本章的固定列表里,第一条已确认记录就会触发异常,最终统计没有显示。若换成一个先保存部分文件、再处理后续资料的程序,后来报错并不会自动撤销先前写出的文件。我们需要沿执行过程判断已经发生了什么,不能只凭最后一个错误词概括整次运行。

反过来,正常结束也只说明这次执行走到了结束位置。它没有自动核实“已确认”是否筛选正确,也不知道用户要的是人数还是笔数。错误类型有助于选择排查路线,业务含义则提供判断对错的参照,两者缺一不可。

四、读traceback:先认出失败操作,再看它从哪里被调用

运行summary_missing.py会出现类似下面的输出。这里保留调用与错误的关键内容,省略机器上的完整文件路径和不同Python版本可能显示的辅助箭头。若按上文原样保存,所列行号对应那份文件;插入空行后应以自己运行时的行号为准。

可复制模板
Traceback (most recent call last):
  File "summary_missing.py", line 28, in <module>
    main()
  File "summary_missing.py", line 23, in main
    result = summarize(RECORDS)
  File "summary_missing.py", line 18, in summarize
    totals[key] = totals.get(key, 0) + read_people(row)
  File "summary_missing.py", line 10, in read_people
    return row["count"]
KeyError: 'count'

最后一行告诉我们发生了哪类异常,以及这里缺少的键名。往上看,最里面的那层调用停在read_people中的字段读取;再向上,能看到它是从summarize更新小计时被调用的,而summarize又由main启动。这样读,长长的英文输出就成了一条具体的到达路线。

traceback可以理解为这次异常的调用踪迹。本例列出的几层,记录了异常出现时尚未返回的调用关系,不是把程序执行过的每一轮循环都打印出来。我们不能从这四层直接得知已经处理了四条报名,也不能把最上面的main()当成唯一的错误原因。

接下来要核对最里面那个操作的输入。read_people期望从count取数,但当前数据约定是people。原表和RECORDS都说明每笔人数放在people,所以这里有明确依据将函数改为:

可复制模板
def read_people(row):
    return row["people"]

保留其他部分不变,只替换同名函数,再运行原来的四条记录,会得到{'001': 3, '002': 3}。这一修改的理由是让字段读取与既定输入对应;结果也与独立计算的预期一致。不能把理由仅写成“把报错那行改到不报错”。

例如,将读取改成row.get("count", 0)也可能让这份程序正常结束,但四条记录里都没有count,于是已确认报名都会按零参与累计,得到的两个活动小计都是零。错误信息消失,真实人数却没有进入计算。默认值是一种有语义的安排,只有任务确实允许把缺失按某个值处理时才适合使用。

同样,如果资料规范原本明确要求count,而输入来自另一个错误转换步骤,那么需要修的可能是上游转换。异常发生位置告诉我们哪里无法继续,究竟修读取还是修输入,还得比较双方原本约定。此处之所以能修read_people,是因为本章已经固定了输入含义,不是在两种解释之间随意选一个。

五、复现:把“刚才出错了”变成别人也能检查的现象

小叶把函数改好后,应当保留这次错误怎样出现的必要信息。复现是让同一问题在明确条件下再次出现,以便判断某个改动是否真的影响了它。本章资料直接写在文件里、处理过程没有随机选择或远端请求,因此一份确定的脚本加运行方式,就能稳定复现这个字段异常。

如果资料来自文件,复现条件还要包括实际读取的是哪一份文件。名为报名表.json的两个文件可以内容不同;同一个相对路径从不同工作目录运行,也可能指向不同位置。只传一个文件名,不一定传递了同一份输入。显示文件的完整路径,或在记录中注明相对路径与运行目录,可以先确认本次定位到哪一份文件。

复现记录不用写成冗长的流水账。对当前案例,可以保存原错误脚本、四条固定输入、运行命令、完整错误输出、Python版本,以及应得到001和002各3人的依据。交给同学或AI后,对方能够重跑,并且知道要比较什么。这比一句“有个KeyError,帮我看看”多出的,是判断所需的条件。

当运行依赖随机生成、当天接口数据或之前留下的文件,保存这些条件尤其重要。重跑时恰好没有错误,可能因为输入改变了,也可能因为某个状态已经更新;它尚不能证明修改起了作用。可以先把发生问题时允许保存的必要输入固定在本地副本上,再排查处理过程。涉及个人资料时,用保留字段关系的虚构记录构造例子,不必把完整真实报名表复制给外部工具。

复现也常伴随删减,但删减要保留能够触发当前偏差的条件。字段异常在第一条记录就会发生,因此只留下R001也能检查错误的键名。后面要研究“同一活动的前一笔被后一笔覆盖”,就至少需要两条属于同一活动的已确认记录。删到只剩一条,程序恰好算对,并不表明原因消失了。

这种保留关键条件、去掉无关内容的例子,常被称为最小复现。这里的“最小”主要服务于理解:让相关数据和操作少到可以看清。无需为了少一行代码把表达压得难读,也无需证明再删掉任何字符都不能复现。能够稳定展示同一种失败,并帮助区分原因,就已经有实际价值。

排查时还要确认修改的文件就是运行的文件。编辑器里改了新副本,终端却一直运行旧副本,会让正确修复看起来毫无效果。观察脚本位置、文件修改是否保存以及实际启动命令,是核对实验条件的一部分。它们不替代业务推理,但能防止我们围绕一次根本没有运行的新修改继续猜测。

六、不报错的版本,要沿中间状态寻找第一次偏离

现在另看一个版本。它使用与前面完全相同的四条记录和统计目标,但把汇总函数写成下面这样。将第一份完整文件另存为summary_overwrite.py,用下列函数替换其中的summarize;其余保留。这个版本直接读取正确的people字段,不再调用原来的read_people,因此旧辅助函数中的字段错误不会在这次调用路径上执行。

可复制模板
def summarize(records):
    totals = {}
    for row in records:
        if row["status"] == "confirmed":
            key = row["activity_id"]
            totals[key] = row["people"]
    return totals

运行得到{'001': 1, '002': 3}。解释器没有报错,因为读取字段、判断状态和字典赋值都是合法操作。要发现问题,需要追踪同一份小计怎样被每条记录改变。

处理到哪条记录 这条记录要求做什么 当前代码处理后的小计
尚未处理 建立空结果 {}
R001 给001计入2人 {'001': 2}
R002 给001再计入1人 {'001': 1}
R003 已取消,不计入 {'001': 1}
R004 给002计入3人 {'001': 1, '002': 3}

第一次明确偏离任务发生在R002。处理它之前,001已经计入2人;处理后却只剩这笔报名的1人。原代码把新人数直接放到001这个键上,旧值随之被替换。它实现的是“这个活动最近处理的一笔人数”,而任务要求“这个活动所有已确认报名的累计人数”。

注意,R001处理后的小计正确,R004对应的002也正确,因为这些位置还没有遇到同一活动的第二笔有效报名。某些结果正确,可能只是输入没有显露错误;这里不能据此认定筛选以后的一切都没问题。沿着状态变化看,比仅盯最终两个数字更容易发现偏差第一次出现的条件。

同一活动两笔报名的覆盖与累加
图 C09-02|同一活动两笔报名的覆盖与累加

两条路线处理的是相同的2人和1人。图中差异发生在第二笔到来时:覆盖只留下新值,累加让先前已经计入的人继续留在结果里。这样就能看清结果字典怎样保留先前已经计入的人数,以及每次更新是否延续了这份累计结果。

我们可以把正确程序在处理中应保持的关系说出来:每处理完一条记录,各活动的小计应分别等于到目前为止已经遇到的、该活动所有已确认报名的人数之和。遇到取消记录,这个小计保持不变;遇到同一活动的新报名,原小计与新人数合并。这样的持续关系常被称为不变式,指每个相应检查时点都应成立的条件,不是说小计数值永远不变化。

不变式给调试提供了沿途参照。最后结果很复杂时,可以先检查前几步是否仍满足这层关系。一旦出现第一处可见偏离,再观察它附近的输入和更新操作,通常比从整个程序里猜一个可疑词更有方向。当然,最早观察到的偏离仍可能由更早的错误数据造成,所以还要核对进入这一段的资料。

七、打印中间值,要围绕一个待判断的问题

为了确认刚才的手工追踪,可以给覆盖版加入临时观察。把其summarize替换为下面这个版本,其余不变,再运行原四条资料。

可复制模板
def summarize(records):
    totals = {}
    for row in records:
        if row["status"] != "confirmed":
            print("跳过", row["record_id"], row["status"])
            continue
        key = row["activity_id"]
        before = totals.get(key, 0)
        totals[key] = row["people"]
        print("更新", row["record_id"], key,
              "原小计", before, "本笔", row["people"],
              "新小计", totals[key])
    return totals

其中的关键观察行是:

可复制模板
更新 R001 001 原小计 0 本笔 2 新小计 2
更新 R002 001 原小计 2 本笔 1 新小计 1
跳过 R003 cancelled
更新 R004 002 原小计 0 本笔 3 新小计 3

这些行之后,main仍会打印最终的{'001': 1, '002': 3}。新增打印展示处理过程,没有改变这个错误版本的更新规则。R002那行同时给出了记录编号、活动、旧值、本笔人数和新值,使“原有的2人在哪里消失”成为可以直接检查的问题。

打印位置也有意义。before在更新前取得当前活动的整数小计;新小计在赋值后读取,两个时点清楚。如果只在循环结束时打印整个字典,就只能再次看到最终错误。如果先更新再把结果叫作“原小计”,日志会误导排查。因此,调试信息除了包含正确的变量,还必须正确标明观察时点。

为什么这里取一个整数,而不把整个字典简单赋给before?如果写before = totals,这两个名字会指向同一个可变字典。后面更新totals时,通过before再看也会看到更新后的内容,不会自动保留历史。对本章的字典,可以用totals.copy()保存当时各键与整数值的浅拷贝;这里直接取出待观察活动的整数已经足够。资料包含嵌套列表时,浅拷贝是否能保留想看的历史,还要另查对象内部的共享关系。

有时我们需要的是“被筛掉了还是更新错了”,就同时观察筛选动作和更新动作;有时只怀疑某个活动,就让观察集中在它的记录上。先明确这次观察要回答什么问题,再决定打印什么。把每个变量在每一行都打印一遍,会产生大量没有比较依据的信息,反而难以看到真正的转折。

诊断输出和交付结果也应分清。小叶给同学的最终统计不需要夹杂几百行临时追踪。调试完成后可以移除这些临时打印,或者在需要时明确开启。若别的程序要直接读取标准输出里的JSON,额外插入诊断文字甚至会使它无法解析;这时更要给结果和诊断安排清楚的去向,而不能把“打印出来了”一概视为相同交付。

八、用反例区分原因,再作有针对性的修改

看到001少了2人,可能有人猜“程序漏读了第一条”,也可能有人猜“第二条覆盖了第一条”。刚才的观察对两种解释给出了不同证据:R001确实被处理,处理后小计确实是2;到R002才变成1。它支持更新覆盖的解释,而不支持“R001从未进入循环”。

这个判断还可以用更小的例子核实。保留R001和R002,其他记录去掉,预期仍是001共3人。覆盖版得到1。如果只保留R001,它又得到2。对这个更新函数来说,同一活动有第二笔已确认报名,正是使错误显露的重要条件。

还可以交换这两笔的顺序。任务按人数求和,不会因为先处理1人还是2人改变总数;覆盖版却会把最后处理的一笔留下来,交换后结果变成2。这个变化进一步符合“旧小计被新值替换”的解释。它提供的是这份代码、这组数据上的证据,不是说所有与顺序有关的程序都有错误:如果任务本来就是取最后一条记录,顺序当然有意义。

有了输入约定、状态观察和具体赋值语句,现在修改更新关系:

可复制模板
totals[key] = totals.get(key, 0) + row["people"]

这行应替换覆盖版循环中的totals[key] = row["people"]。get(key, 0)在活动还未进入结果时提供零起点;活动已经有小计时,取出已有值。新人数与它合并后再放回字典。这样,第二笔报名不会抹掉第一笔,另一活动也从自己的小计开始。

与前面缺字段时把count默认成零不同,这里的零有明确含义:结果中还没有这个活动,说明迄今还没有为它累计已确认人数。原始记录缺了人数,则意味着这笔该加多少未知。一个是计算过程的合法起点,一个是输入信息缺失,不能因为两处都能写get(..., 0)就认为处理理由相同。

修改后重跑两笔同活动记录,两种顺序都得到3;再跑原四条,得到001和002各3。原失败情景被解决,而且修改理由与业务关系一致。只是把输出硬改成{'001': 3, '002': 3}也能过原四条的比较,却不能根据新的输入计算人数,因此还需要有意义的变化来检查。

这里“一次有针对性的修改”指修改围绕同一个已定位原因,便于解释它为何起作用。并不是每次只能改一个字符:如果字段约定在三个紧密相关位置都写错了,一次统一修正可能更合理。需要避免的是把算法、输入、输出格式和运行方式同时换掉,然后仅凭结果变好,声称其中某个改动已经得到验证。

假说被证据否定也有价值。如果打印发现进入汇总函数的资料就只剩R002,那么该去查上游筛选或读取,而不是坚持在加法上补代码。调试不是给最初的猜测找支持,而是通过观察逐步排除不符合实际的解释。

九、断点让程序在关键位置停下来

临时打印适合观察少量已知信息。当小叶想在同一个时点查看几个值、再决定下一步看哪里,可以使用调试器。断点是在指定位置暂停执行的安排,单步则让程序继续执行一小段后再次暂停。它们让运行过程变得可观察,不会自动判断任务是否完成。

Python自带的pdb可用于这类观察。在普通终端按默认调试设置运行时,内置breakpoint()会进入它;某些开发环境或环境变量可以改变这一入口的行为。有关默认入口,见Python的breakpoint说明。本章使用默认设置,不要求安装额外扩展。

从没有临时打印的覆盖版另存summary_debug.py,把汇总函数换成下面代码,再在终端运行这个文件:

可复制模板
def summarize(records):
    totals = {}
    for row in records:
        if row["status"] == "confirmed":
            key = row["activity_id"]
            if row["record_id"] == "R002":
                breakpoint()
            totals[key] = row["people"]
    return totals

只在R002时暂停,是因为上一轮观察已经把问题缩到它的更新附近。停住后可以看到(Pdb)提示符。Python版本不同,屏幕所指行可能显示断点调用处或紧邻的下一条语句;本例的关键是观察时尚未执行R002的覆盖赋值。在本章使用的Python 3.13中,初次停住时的关键画面如下,完整文件路径用省略号代替:

可复制模板
> .../summary_debug.py(19)summarize()
-> breakpoint()
(Pdb)

第一行说明当前文件、行号和函数;->标出暂停处的源代码,(Pdb)是等待输入调试命令的提示符。先输入以下三条命令,每条按一次回车:

可复制模板
p row["record_id"]
p totals
p row["people"]

三次查看分别得到'R002'、{'001': 2}和1。p会计算并显示后面的表达式,所见是当前暂停位置的值。接着看箭头:本章实际使用的Python 3.13会先停在breakpoint()这一行,输入一次n,才停到尚未执行的覆盖赋值前;所用的Python 3.12则已经停在该赋值前。先把暂停位置对准这次赋值,再输入n执行它,随后用p totals会看到001变成1。最后输入c继续,程序处理其余记录并显示完整错误结果。

如果实际暂停的位置不同,先看当前箭头和附近源代码,确认目标赋值是否已经执行,再比较前后状态,不应机械数回车次数。我们要观察的是“执行这次更新前后发生了什么”,不是背下某个工具画面必定出现在哪一行。

n通常用于执行当前行,并在当前函数的下一行或返回时停下;s会允许进入被调用函数的内部,适合需要查看辅助函数怎样处理输入时使用。c恢复继续执行,到下一个断点等停止位置再停。更多说明见Python调试器命令。这几个动作的选择,仍由你当前想查哪层关系决定。

调试器中的表达式也会执行。查看totals只是在读值,如果输入的是会修改列表、写文件或发请求的调用,就可能改变正在观察的状态。对当前任务,我们只读取记录和小计,再让原程序按顺序运行。这样得到的前后比较才与原本的失败过程对应。

断点只是另一种取证方式。只有几行的错误,用纸笔追踪或临时打印已经足够;程序层次多、需要反复看调用和变量时,调试器会更方便。理解输入、状态和目标的关系,才是这些工具共同服务的能力。

十、让AI提出可检查的解释,而不只是再交一份代码

小叶现在可以把调试任务交得更具体。需要交出的材料包括目标、发生问题的那一版代码、能复现的资料、实际结果与预期差别,以及已经取得的关键观察。对于覆盖问题,R001更新后是2、R002更新后变成1,这两条信息比“程序有时不准”更能约束后续判断。

一次有效的协作可以这样开始:“这份程序应按活动累计已确认报名的人数。四条虚构输入如附件,001期望3,实际1。追踪表明R001已进入累计,R002后从2变为1。请先指出哪条操作能解释这个变化,再给出对应修改和验证方法。”这里并没有预先把所有答案写给AI,但已经给它一个能够被检查的任务。

AI若提出“可能是取消记录过滤不正确”,小叶可以问:这个解释如何说明001的变化?001的两条记录都是已确认,当前观察也显示它们都进入更新。假说若不能解释已知事实,就不能仅凭措辞专业进入修改。AI若提出“用旧小计加本笔人数”,则可以把该方案代回R002,看已有2人是否继续保留,并实际运行验证。

另一种有用的分工是让AI先描述它理解的执行过程。要求它说明输入从哪里来、哪种记录被跳过、小计在哪一行更新、最终结果从哪里返回。小叶用原代码和观察逐项核对。如果AI把函数定义顺序当成调用顺序,或者把记录条数当人数,这种解释本身已经暴露了需要纠正的理解,无需先接受整份重写。

修改的交付也应能对照。列出实际改动的代码,说明它解决哪一步的问题,再附上“原失败资料现在输出什么,哪种相近变化也检查过”,通常足够说明这一轮做了什么。若AI同时加入缓存、改变数据格式和另建一个界面,新的方案可能有用途,但它已经扩大了本轮工作。此时要弄清是确有必要,还是把一个简单累计错误变成了更难核对的新系统。

有时AI能够直接运行程序,有时只能阅读文本。两者提供的证据不同。“按代码推导应输出3”是推理,“在这份输入和这个版本下实际运行得到3”是观察。推理可以帮助设计检查,运行可以验证特定行为;都不应被含糊地写成“已经测试没问题”。保存实际命令、输出和版本,让下一轮能复查它们的对应关系。

小叶也可以要求AI给出一个可能推翻当前解释的例子。例如,若认为错误来自覆盖,就观察同一活动两笔不同人数及交换顺序;若认为错误来自取消状态,就保留人数相同、状态不同的记录比较。这里的价值是找到能区分原因的信息,而不是把AI当成会替自己承担判断的权威。

当AI生成内容参与输入时,还要把生成和后续确定性计算分开。可以保存某次生成的结构化资料,先固定它检查汇总,再单独判断生成结果是否符合原始来源。否则,每次让AI重新生成数据,输入与代码同时变化,哪一部分造成差异就更难知道。这里固定输入是为了研究某一段处理,不代表这份生成资料已经真实可靠。

十一、当前函数正确,交付仍错误时,继续沿数据去向查

假设小叶修好累计函数,终端已经显示两个活动各3人,但同学打开的文件仍显示001为1。这是另一个明确的新情景:我们现在已经观察到本次计算正确,同时看到了另一处交付仍旧。应该把问题定位到两份观察之间,而不是重新随机改累计公式。

可以先确认终端结果是否来自刚修改的脚本,再检查保存代码拿的是不是这份新结果、写入了哪个完整路径、写入动作是否成功,以及同学打开的是哪个文件。若小叶核对发现,本次写到output/summary.json,同学一直打开archive/summary.json,两条路径不同便解释了当前现象。解决办法是把当前结果交到约定入口,并核对那个入口读回的内容。

如果尚未核对路径,只知道“终端对、文件错”,就不能直接宣布原因一定是缓存。可能没有执行保存,可能保存时传了旧变量,也可能查看的是另一份文件。这些解释可以通过路径、保存前的数据、保存是否完成和实际读回内容区分。调试应随新证据缩小范围,而不是在信息不足时选一个听起来熟悉的技术词。

这体现了程序阅读中的数据流意识。同一份资料从读取到筛选、汇总、格式化、保存,经过若干交接。选择其中几个有明确含义的位置观察,就能判断偏差是在进入某段处理前已经存在,还是经过它以后出现。但如果只看“读到了4条”,还不足以证明读入内容正确;四条错记录同样是4条。检查要与疑问相对应,必要时比较编号、状态和人数本身。

前后各看一次也不是一条自动找出根因的公式。多处错误可能碰巧抵消,有些错误只在特殊输入下出现。它的实际作用,是在这次已知失败上减少无目的搜索。假如进入汇总的资料就已缺R001,应先查读取和筛选;假如汇总结果正确、保存前却变回旧字典,则查这两个位置之间的赋值和交接。

程序规模更大时,保持这样的关系图很有帮助:这一步接收什么,它应保证什么,下一步实际拿到什么。读者不需要把所有代码装进脑中,也能针对当前结果沿关键路径检查。软件工程课程会进一步讨论怎样通过系统结构减少这种交接困难;这里先把它作为读懂与调试已有程序的方法。

十二、回到报名统计:交出修复,也交出判断依据

小叶最终保留一个直接读取人数、按活动累计的汇总函数。将覆盖版中的函数替换成下面这份,保留原RECORDS和main,保存为summary_fixed.py并运行。旧的read_people在这条实现中不被调用,可以删除,避免让读者误以为它仍参与处理。

可复制模板
def summarize(records):
    totals = {}
    for row in records:
        if row["status"] == "confirmed":
            key = row["activity_id"]
            totals[key] = totals.get(key, 0) + row["people"]
    return totals

四条资料的结果是{'001': 3, '002': 3}。其中001保留了两笔已确认报名,002排除了已取消的4人。我们可以解释每个结果来自哪些记录,也可以指出原覆盖语句为什么无法维持累计关系。

为了检查修改没有只适应原例,将RECORDS替换成不同的小列表,每次独立运行,得到下列关系。表中的期望先由任务含义确定,再与运行结果比较。

输入变化 应当观察到什么 主要检查的关系
只保留R001和R002,并交换顺序 两种顺序都为{'001': 3} 累计不应只留下最后一笔
只保留R003 {} 已取消的报名不进入结果
保留R001,把人数改为5 {'001': 5} 程序使用输入人数,未写死原答案
不给任何报名记录 {} 没有有效记录时自然返回空结果
保留R001和R004 {'001': 2, '002': 3} 不同活动分别累计

这些检查针对这次修复的原因和相邻关系,能够提供比“原例又跑对一次”更多的证据。它们仍不表示这段函数能够接受任意外来资料。本章固定输入的字段齐全、人数是正整数、状态使用已约定文字;若真实输入不再满足这些条件,需要在进入统计前核实、转换或拒绝,不能让小样本通过替代输入约定。

小叶的修复记录现在能够简洁写清:目标是按活动累计已确认人数;第一版字段名不符合输入,改为people后原例正确;另一个覆盖版在同活动第二笔到来时丢失先前小计,改为累加后原例及所列变化符合预期。两种错误各有自己的原因和证据,不能把它们混成一次神秘的“AI修好了”。

这章学到的程序阅读,是把代码放回输入、调用、状态和输出之间理解;调试则把观察到的偏差转成可复现、可区分、可验证的问题。掌握这层能力以后,小叶仍然可以让AI写大部分代码,但在程序报错或结果异常时,已经能够提出有依据的问题、检查修改的作用,并知道当前结论覆盖到哪里。

下一章进一步讨论怎样选择测试资料和验证结果,让检查不只在出错以后发生。这里已经完成了一次具体的修复过程:从两个不可靠版本出发,找到字段约定与累计关系各自的偏差,得到可运行的当前实现,并保留足以让别人复查的依据。

练习与参考分析

练习一:错误出现在读取处,就一定要修改读取吗

另一个报名系统明确规定,人数统一存放在count字段。它的汇总函数读取row["count"],但上游转换器实际交出了{"activity_id": "001", "people": 2},于是出现KeyError: 'count'。有人建议照本章改为读取people。这个建议能直接采纳吗?应当补查什么?

**参考分析:**这次约定与正文案例不同。读取处是异常显现位置,但按题面,它读取的是约定字段;上游转换器没有交出约定结构。首先应核对转换器原本应生成什么,以及其他使用者是否仍依赖count。如果系统约定没有变,通常应修转换,让转换器把人数写入约定的count字段,再用原失败输入重跑整段交接。

也可能团队确实准备统一改用people,但那是字段约定的变化,需要让生产和使用这一资料的相关部分一致,不能只在一个报错位置临时兼容。无论采用哪种方案,都应保留这笔人数2的含义。用get("count", 0)让当前运行得到零,会把缺失信息伪装成无人报名,不能完成题目任务。调试不是记住某个字段拼写,而是找出实际输入与应有约定在哪里分离。

练习二:删减后的例子不出错,说明修好了什么

小叶研究覆盖版时,只留下R001,得到001为2,于是说“简化后正常,原错误应该是数据太多”。随后把R002也放回,结果变成001为1。应怎样评价这个解释,并设计一个更有意义的最小例子?

**参考分析:**一条记录时,累计和保留最后一笔会得到相同结果,它没有区分两种更新规则。“数据太多”也没有解释第二条为什么恰好使结果等于本笔人数。保留R001、R002,两笔同活动且已确认,足以显露覆盖:预期3,实际1。再交换顺序,覆盖版留下2,进一步显示结果受最后一笔支配。

这里删减应保留的条件是同一活动至少两笔有效报名,而不是保留庞大的文件。可以去掉其他活动、取消记录和无关显示,但不要删掉让错误显露的关系。原程序是否另有其他问题仍可继续检查;这个最小例子只服务于当前已观察到的覆盖问题,不声称囊括整个系统。

练习三:日志里的“之前”为什么已经变了

小叶在覆盖赋值前写before = totals,赋值后打印before与totals,两边都显示001为1。小叶因此认为更新前就已经是1,错误应该更早发生。这个判断有什么问题?怎样取得能够比较的证据?

**参考分析:**两个名字指向同一个字典,赋值以后通过任一名字读到的都是当前字典。它们不是两个时间点的快照,所以两份打印相同不能证明更新前已经是1。代码把变量叫作before,不会自动给它历史保存能力。

针对这个问题,在更新前取出当前活动的整数小计,更新后再读取当前值,就能比较2如何变成1;也可以在本章这种“键对应整数”的结构下,用totals.copy()保留当时字典。关键是观察确实落在两个时点,而且旧观察没有被后来的修改改变。对嵌套可变资料,浅拷贝还可能共享内部对象,需要按实际结构决定怎样保存,不能把此例办法不加检查地外推。

练习四:断点看到的值,应当支持哪种结论

在覆盖版处理R002时,小叶确认暂停位置尚未执行更新。此时totals为{'001': 2},当前人数为1。执行该更新后,小计变成1。另一位同学说:“我们只要让调试器中的小计改成3,再继续运行,就证明程序已经修好了。”你怎样回应?

**参考分析:**这组前后值可以支持“这次更新覆盖旧小计”的判断。临时在调试器中改变值,可以探索后续过程在不同状态下会怎样,但没有修改原程序产生状态的规则。重新启动原代码,仍可能在R002把2覆盖成1,所以不能以一次人工调整后的结果证明修复。

实际修复应把覆盖改为按任务累计,退出这次调试,从同一份原始输入重新运行。再检查顺序交换等相邻情景,确认结果由代码产生。调试时主动改变状态和仅观察状态,是两种不同实验;都可以有用途,但报告中要说明发生了哪一种。

练习五:AI重写时删掉一条记录,能否比较修改效果

AI接到四条记录的覆盖问题后,同时重写函数、删掉R001,并调整输出格式。新版本显示001为1、002为3,AI解释“所有输入记录都已正确统计”。这能否证明原问题已解决?应怎样补做核对?

**参考分析:**对删去R001后的输入,001确实只剩1人,但原任务要求处理的是包括R001的四条资料。改变输入以后,输出看起来合理,不等于原来丢掉2人的问题得到修复。至少要把原输入恢复,按原先独立确定的各3人进行比较,并查看更新规则是否真正保留同活动的已有小计。

输出格式变化可以单独核对,只要编号与人数的含义保持。函数修改也可能正确,但现有运行没有验证原失败条件。应让AI明确哪些是算法改动、哪些是样例删减,补跑同一原输入和一组能区分覆盖与累加的资料,再对结果作判断。不能把“新任务上正确”直接当作“旧任务已修复”。

练习六:任务改为数报名笔数,人数相加还是正确修复吗

老师现在要比较各活动收到了多少笔已确认报名,每笔无论几人都计一笔。仍用原四条资料,小叶保留人数累加函数,得到两个活动各3。程序正常运行,之前所有人数测试也仍通过。应怎样判断?

**参考分析:**目标已经从人数改为报名笔数。001有两笔已确认,002有一笔已确认,新的期望是{'001': 2, '002': 1}。旧函数按原人数任务是正确的,但不符合新目标;过去人数测试仍通过,只能说明它继续做了旧任务。

新的更新应当让每条已确认记录为对应活动增加一笔,取消记录不计。解释和输出名称也要改成笔数,避免继续把值叫作人数。选择包含一笔多人报名的例子很有帮助:它能区分“计一次报名”和“计其中几个人”。这说明判断错误始终依赖目标与数据含义,不能脱离任务把某段累加代码永久认作正确答案。