AI 辅助编程(一):读代码、审代码、信不信代码
AI-Assisted Programming I: Reading, Reviewing and Trusting Code
从这一讲开始,你可以用 AI 写代码了。但在按下回车之前,你得先有能力判断它给你的东西对不对——这一讲就是关于这个判断力。
本讲结束后你应当能
- 说出 LLM 生成代码时最常见的三类错误,并解释它们为什么会发生
- 把函数签名和类型提示当作提问的规格说明
- 用 PEP 8 作为审查代码的检查表,而不只是格式规范
- 用调试器而不是 print 定位问题
- 写出合格的 AI 使用声明,并说清楚学术诚信的边界
本页目录
为什么是现在
前七周你不能用 AI 写代码。今天开始可以了。
在讲怎么用之前,先把为什么等到现在说清楚——否则这条规则就只是一道行政命令。
这七周你建立的是两样东西:代码直觉和调试能力。看到一段代码能预判它输出什么;看到一个报错能猜到问题在哪一行。你在 L2 被浮点数骗过,在 L5 被 [[0]*n]*m 坑过,在 L7 见过 RecursionError。这些经历没有捷径。
如果这七周交给 AI,你会得到一堆能跑的代码,和零判断力。
而从今天起,判断力恰恰是你最值钱的东西。因为 AI 会写出看起来完全合理、实际上在某个边界条件下崩溃的代码——而它写得那么流畅、那么自信,你很难仅凭”看起来对”发现问题。
这一讲不教你怎么让 AI 写得更多,教你怎么判断它写得对不对。
一、AI 是怎么”写”代码的
它在预测,不在验证
大语言模型的工作方式,本质上是根据前面的内容预测下一个词(准确地说是 token)。它读过海量代码,学到了”在这种上下文里,接下来通常出现什么”。
关键在于:它没有运行过你的代码,也没有验证过它的正确性。 它输出的是”看起来最像正确答案的东西”。
绝大多数时候,看起来像和真的是重合的——所以它很有用。但这两者分开的时候,就是你要出手的时候。
三类典型错误
一、幻觉 API(hallucinated API)——调用了根本不存在的函数或方法。
s = "hello"
print(s.reverse()) # 字符串没有 reverse 方法
模型见过 list.reverse(),也见过大量”反转”的语境,于是”合理地”推断出 str.reverse()。但字符串是不可变的,它没有这个方法。
这类错误相对好发现——因为它会直接报错。
二、边界条件失守——主干逻辑对,但空输入、单元素、越界这些情况没处理。
def average(nums: list[float]) -> float:
return sum(nums) / len(nums)
print(average([1, 2, 3])) # 正常
print(average([])) # 空列表
这类错误危险得多,因为它在你测试的时候完全正常,等到真实数据里出现空列表才炸。
三、过时或错误的写法——训练数据里包含大量旧代码。
# 模型可能给你 Python 2 的写法
print "hello"
或者给你一个已经被废弃的库、一个已经改了签名的函数。
二、怎么问
用签名而不是描述
你已经在 L4 学过类型提示,当时我说过它的第三个用途——它是你给 AI 的规格说明。现在展开讲。
对比两种问法:
❌ “帮我写一个函数,合并两个列表,要排好序的”
这句话有太多没说清楚的:输入是什么类型?两个列表本来就有序吗?重复元素怎么办?返回新列表还是原地改?
✅ 给它这个:
def merge_sorted(a: list[int], b: list[int]) -> list[int]: """把两个已升序排列的整数列表合并成一个新的升序列表。 保留重复元素。不修改输入。 """ ...
三行代码说清了自然语言一百个字说不清的事。函数签名是一份可执行的规格。
给上下文,给约束,给例子
一个好的提问包含:
- 你要做什么——目标,不是步骤
- 约束——不能用哪些库、性能要求、代码风格
- 例子——输入什么、期望输出什么,特别是边界情况
写一个函数 count_words(text: str) -> dict[str, int],统计词频。
要求:
- 忽略大小写
- 去掉单词两端的标点
- 只用标准库
例子:
count_words("The cat. THE dog") → {"the": 2, "cat": 1, "dog": 1}
count_words("") → {}
注意最后那个空字符串的例子。 主动给出边界情况,能显著降低第二类错误的发生率——你把它逼着去想了。
一次问一小步
这是 L7 分治思想的直接应用。
一次让 AI 写三百行,你没法验证;一次写一个函数,你能读完、能测试、能判断。把大问题拆开,一个一个来——这一步是你的工作,不是 AI 的。
三、怎么读:把 PEP 8 当检查表
你要审查代码,就得有标准。Python 的官方风格规范叫 PEP 8。
过去教 PEP 8 是当作”你要遵守的规矩”。现在换个定位:它是你审查代码的检查表。因为 Guido 有一个关键洞见:
代码被阅读的次数,远多于被编写的次数。
AI 时代这句话的权重更大了——你读代码的时间会远多于写代码的时间。
缩进与布局
- 每级缩进 4 个空格,不要用 Tab(L4 讲过)
- 每行不超过 79 个字符(很多团队放宽到 100 或 120)
- 顶层函数之间空两行,类内方法之间空一行
续行要对齐:
# 好:参数对齐到开括号
result = some_function(first_argument,
second_argument)
# 好:额外缩进一级
result = some_function(
first_argument,
second_argument,
)
二元运算符换行
Knuth 的传统建议:在运算符之前换行,这样每一行的开头就能看出它在做什么运算。
# 差
income = (gross_wages +
taxable_interest -
ira_deduction)
# 好
income = (gross_wages
+ taxable_interest
- ira_deduction)
import 规范
import 放在文件最前面,紧跟在模块注释和文档字符串之后,分三组,组间空行:
# 1. 标准库
import os
import sys
# 2. 第三方库
import numpy as np
# 3. 本项目的模块
from mypackage import helper
不要写 from module import *——你不知道它引入了什么名字,可能悄悄覆盖你的变量。
空格:三条最常见的
一、括号内侧不要加空格
# 好
spam(ham[1], {eggs: 2})
# 差
spam( ham[ 1 ], { eggs: 2 } )
二、逗号、冒号、分号前面不要加空格
# 好
if x == 4:
print(x, y)
# 差
if x == 4 :
print(x , y)
三、二元运算符两侧各加一个空格,但优先级不同时可以用空格体现层次
# 好
i = i + 1
x = x*2 - 1 # 用空格体现 * 比 - 优先
c = (a+b) * (a-b)
# 差
i=i+1
x = x * 2 - 1 # 看不出层次
关键字参数的等号两侧不加空格——除非带了类型标注:
def f(x, y=2): # 好
...
def g(x: int, y: int = 2) -> int: # 有标注时加空格
...
命名规范
| 对象 | 写法 | 例子 |
|---|---|---|
| 变量、函数 | snake_case | student_count, is_prime |
| 常量 | UPPER_CASE | MAX_RETRY, PI |
| 类 | PascalCase | Triangle, BankAccount |
| 模块、包 | lowercase | utils, mypackage |
| 内部使用 | _leading_underscore | _helper |
还有一条 L3 提过的:不要用 list、str、sum、type、id 这些内置名当变量。
让工具做格式,你做逻辑
格式问题不该消耗你的注意力。装一个自动格式化工具:
pip install black ruff
black your_file.py # 自动排版
ruff check your_file.py # 检查风格和常见问题
VS Code 里可以配置保存时自动格式化。把格式交给工具,把注意力留给逻辑——这也是你审查 AI 代码时该有的分工:不要花时间挑空格,去看它的逻辑对不对。
四、怎么验
先读懂报错
这七周你已经见过不少了。整理一下最常见的:
| 错误 | 含义 | 典型原因 |
|---|---|---|
SyntaxError | 语法不合法 | 缺冒号、括号不配对、中文标点 |
IndentationError | 缩进有问题 | 缩进不一致、混用 Tab |
NameError | 用了没定义的名字 | 拼写错误、变量还没赋值 |
TypeError | 类型不对 | 字符串加数字、参数个数不对 |
ValueError | 类型对但值不合法 | int("abc") |
IndexError | 下标越界 | a[len(a)] |
KeyError | 字典里没这个键 | 直接 d[k] 而没用 get |
ZeroDivisionError | 除以零 | 空列表求平均 |
AttributeError | 对象没这个属性/方法 | 幻觉 API 最常见的表现 |
RecursionError | 递归太深 | 缺 base case |
看到红字的第一反应应该是读它,不是慌。 traceback 的最后一行说的是什么错,倒数第二行说的是在哪一行。
从 print 升级到调试器
print 调试有用,但它有三个问题:改代码、看不到全部变量、看完还要删干净。
调试器(debugger)能让你在任意一行暂停,查看所有变量的当前值,然后一步一步往下走。
VS Code 里的用法:
- 在你想暂停的那一行左侧空白处点一下,出现红点(断点)
- 按 F5 启动调试
- 程序会停在断点处,左侧面板显示所有变量的值
- 用这几个键往下走:
| 键 | 作用 |
|---|---|
| F10 | 执行当前行(不进入函数内部) |
| F11 | 执行当前行(进入函数内部) |
| Shift+F11 | 跳出当前函数 |
| F5 | 继续运行到下一个断点 |
用测试验证,而不是”看起来对”
这是这一节最重要的一句话。
AI 生成的代码,你判断它对不对的依据不能是”读起来很合理”。 因为它就是被训练成生成”读起来很合理”的东西的。
依据只能是:给它一组输入,检查输出是不是你期望的——尤其是边界情况。
def average(nums: list[float]) -> float:
return sum(nums) / len(nums)
# 自己动手测一遍,尤其是边界
tests = [
([1, 2, 3], 2.0),
([5], 5.0),
([-1, 1], 0.0),
]
for nums, expected in tests:
got = average(nums)
print(f"average({nums}) = {got} {'✓' if got == expected else '✗ 期望 ' + str(expected)}")
上面这段手写的检查已经很有用了。L11 会教你用 pytest 把它规范化,而实验平台就是用同样的机制给你判分的。
边界情况清单
审查任何代码时,问这几个问题:
- 空输入:空列表、空字符串、空字典
- 单个元素:只有一个元素时逻辑还成立吗?
- 重复元素:会不会算重了、删错了?
- 负数和零:
0、-1会怎样? - 极大值:数据量翻一千倍会怎样?
- 类型不符:传进去一个字符串会怎样?
五、课堂活动:找出四段代码的问题
下面四段代码都是”AI 可能会给你的东西”。每一段都有一个问题。先自己找,再往下看解析。
代码一
def remove_all(items: list[int], value: int) -> list[int]:
"""删除列表中所有等于 value 的元素"""
items.remove_all(value)
return items
print(remove_all([1, 2, 3, 2, 4], 2))
代码二
def max_gap(nums: list[int]) -> int:
"""返回排序后相邻元素的最大差值"""
s = sorted(nums)
gaps = [s[i + 1] - s[i] for i in range(len(s) - 1)]
return max(gaps)
print(max_gap([3, 9, 1, 15])) # 正常
print(max_gap([7])) # 只有一个元素
代码三
def dedup(items: list[int]) -> list[int]:
"""去重且保持顺序"""
result: list[int] = []
for x in items:
if x not in result: # 注意这一行
result.append(x)
return result
print(dedup([3, 1, 3, 5, 1])) # 结果是对的
# 但是数据量大的时候呢?
import time
big = list(range(10000)) * 2
t = time.time()
dedup(big)
print(f"1 万个不同元素去重耗时 {time.time() - t:.2f} 秒")
代码四
def f(l, D):
R = []
for i in range(0, len(l)):
if l[i] in D:
if D[l[i]] > 0:
R.append(l[i])
return R
print(f(["a", "b", "c"], {"a": 1, "b": 0, "c": 5}))
解析
代码一:幻觉 API。 列表没有 remove_all 方法。运行会报 AttributeError。这是最容易发现的一类——它会直接崩。
正确写法:
def remove_all(items: list[int], value: int) -> list[int]:
return [x for x in items if x != value]
print(remove_all([1, 2, 3, 2, 4], 2))
代码二:边界条件失守。 只有一个元素时,gaps 是空列表,max([]) 报 ValueError。逻辑主干完全正确,但少了一个判断。
def max_gap(nums: list[int]) -> int:
if len(nums) < 2:
return 0 # 或者抛一个有意义的异常
s = sorted(nums)
return max(s[i + 1] - s[i] for i in range(len(s) - 1))
print(max_gap([3, 9, 1, 15]), max_gap([7]), max_gap([]))
代码三:复杂度陷阱。 结果完全正确,但 x not in result 是在列表里查找——每次都要从头扫一遍。数据越多越慢,而且是成平方地慢(L5 提过列表查找的代价)。
用集合就快得多(L6 讲过):
def dedup(items: list[int]) -> list[int]:
seen: set[int] = set()
result: list[int] = []
for x in items:
if x not in seen: # 集合查找,几乎一步到位
seen.add(x)
result.append(x)
return result
import time
big = list(range(10000)) * 2
t = time.time()
dedup(big)
print(f"改用集合后耗时 {time.time() - t:.4f} 秒")
代码四:风格灾难。 逻辑是对的,但:
- 函数名
f什么也没说明 - 参数
l、D无意义,而且l和1难以区分(PEP 8 明确禁止) - 变量
R、大写字母开头,违反命名规范 - 没有类型提示、没有文档字符串
range(0, len(l))里的0多余,而且应该直接遍历元素- 三层嵌套的
if可以合并
改写:
def filter_positive(keys: list[str], counts: dict[str, int]) -> list[str]:
"""返回 keys 中那些在 counts 里且计数为正的键。"""
return [k for k in keys if counts.get(k, 0) > 0]
print(filter_positive(["a", "b", "c"], {"a": 1, "b": 0, "c": 5}))
从 9 行变成 3 行,而且读一遍就知道它在干什么。
六、你和 AI 分别该做什么
原来这门课有一节讲”编程心法”。放到今天,它变成了一个分工问题。
| 环节 | 谁来做 | 为什么 |
|---|---|---|
| 理解问题、拆解 | 你 | AI 不知道你真正想要什么 |
| 设计模块划分、定接口 | 你 | 这决定了整个程序的结构 |
| 写单个函数的实现 | 可以给 AI | 有明确签名和例子的活 |
| 写样板代码、格式转换 | 可以给 AI | 机械、重复、容易验证 |
| 判断性能和复杂度 | 你 | AI 很容易给出能跑但慢的方案 |
| 设计测试用例 | 你(AI 可以补充) | 边界情况要靠你的判断 |
| 决定要不要采纳 | 你 | 你对交上去的每一行负责 |
几条原则:
谋定而后动。 想清楚再写。这一条从来没变过,只是现在”想”和”写”可以交给不同的主体了。
不要用来路不明的写法。 这条在 AI 时代权重翻倍。AI 给你一个你没见过的函数或写法,先查文档确认它存在、确认它做的是你以为的事,再用。
先实现功能,再优化性能。 但要能识别出”这个方案在数据量大的时候会死”——代码三就是例子。
七、学术诚信
AI 使用声明
从本周起,每次提交都要附一段声明,说清三件事:
- 用了什么——哪个工具、哪个模型
- 生成了哪部分——具体到函数或代码段
- 你如何验证——跑了什么测试、检查了哪些边界情况
一个合格的例子:
AI 使用声明:
用了 DeepSeek 帮我写 parse_line() 的正则表达式部分。
我给了它三个输入样例和期望输出。
它第一版没处理行尾多余空格,我加了 .strip() 修正。
另外用空字符串和只有分隔符的行测过,都正常。
其余代码为自己编写。
声明本身不扣分。不写声明才扣分——因为那意味着你根本没在思考自己和 AI 的分工。
边界在哪
按学术不端处理的情况:
- Level 0 期间(第 1–7 周)使用 AI 生成代码
- 任何阶段提交自己讲不清楚的代码
- 口头验收时无法解释自己代码的实现思路
判断标准就一句话:你能不能对自己交上来的每一行代码负责。
小结
- LLM 在预测下一个 token,不在验证正确性。它输出的是”最像正确答案的东西”
- 三类典型错误:幻觉 API(会报错,好发现)、边界条件失守(危险)、过时写法
- 还有一类最隐蔽:算得对但复杂度爆炸
- 提问用函数签名 + 类型提示 + 边界情况的例子,比自然语言描述精确得多
- 一次问一小步。读不完的代码等于没验证
- PEP 8 是审查检查表:缩进、命名、空格、import。格式交给
black/ruff - 从 print 升级到调试器:断点、单步、变量面板、调用栈
- 判断代码对不对的依据是测试,不是”读起来合理”
- 边界清单:空、一个、重复、负数和零、极大值、类型不符
- 你负责理解、拆解、设计、判断性能、决定采纳;AI 负责有明确规格的实现
- 每次提交附 AI 使用声明。你要能对每一行负责
练习
- 找出下面这段代码的两个问题(一个会报错,一个不会),并改对:
def most_common(words: list[str]) -> str: counts = {} for w in words: counts[w] += 1 return max(counts, key=counts.get) - 给下面这个需求写一份给 AI 的规格(函数签名 + 文档字符串 + 至少三个例子,含边界情况): “把一个成绩列表转成等级列表”
- 用 AI 生成一个”判断字符串是否是合法的 IPv4 地址”的函数。然后:
- 不看它的代码,先自己列出至少 6 个测试用例(含边界)
- 用你的用例测它
- 记录它有没有失败,失败在哪
- 把 L7 里你写的任意一个递归函数交给 AI,让它改写成循环版本。检查改写后是否等价,特别检查边界情况。
- 安装
ruff,对你 Lab 1 到 Lab 7 的代码跑一遍,看看它报了多少问题。挑三条你不理解的,查清楚为什么。 - 阅读 PEP 8 的 “A Foolish Consistency is the Hobgoblin of Little Minds” 一节,用一句话说明它在讲什么。