CS1602计算导论
第 8 讲Part 3 问题求解与 AI 协作AI Level 1

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_casestudent_count, is_prime
常量UPPER_CASEMAX_RETRY, PI
类PascalCaseTriangle, BankAccount
模块、包lowercaseutils, 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 里的用法:

  1. 在你想暂停的那一行左侧空白处点一下,出现红点(断点)
  2. 按 F5 启动调试
  3. 程序会停在断点处,左侧面板显示所有变量的值
  4. 用这几个键往下走:
键作用
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 使用声明

从本周起,每次提交都要附一段声明,说清三件事:

  1. 用了什么——哪个工具、哪个模型
  2. 生成了哪部分——具体到函数或代码段
  3. 你如何验证——跑了什么测试、检查了哪些边界情况

一个合格的例子:

AI 使用声明:
用了 DeepSeek 帮我写 parse_line() 的正则表达式部分。
我给了它三个输入样例和期望输出。
它第一版没处理行尾多余空格,我加了 .strip() 修正。
另外用空字符串和只有分隔符的行测过,都正常。
其余代码为自己编写。

声明本身不扣分。不写声明才扣分——因为那意味着你根本没在思考自己和 AI 的分工。

边界在哪

按学术不端处理的情况:

  • Level 0 期间(第 1–7 周)使用 AI 生成代码
  • 任何阶段提交自己讲不清楚的代码
  • 口头验收时无法解释自己代码的实现思路

判断标准就一句话:你能不能对自己交上来的每一行代码负责。

小结

  • LLM 在预测下一个 token,不在验证正确性。它输出的是”最像正确答案的东西”
  • 三类典型错误:幻觉 API(会报错,好发现)、边界条件失守(危险)、过时写法
  • 还有一类最隐蔽:算得对但复杂度爆炸
  • 提问用函数签名 + 类型提示 + 边界情况的例子,比自然语言描述精确得多
  • 一次问一小步。读不完的代码等于没验证
  • PEP 8 是审查检查表:缩进、命名、空格、import。格式交给 black/ruff
  • 从 print 升级到调试器:断点、单步、变量面板、调用栈
  • 判断代码对不对的依据是测试,不是”读起来合理”
  • 边界清单:空、一个、重复、负数和零、极大值、类型不符
  • 你负责理解、拆解、设计、判断性能、决定采纳;AI 负责有明确规格的实现
  • 每次提交附 AI 使用声明。你要能对每一行负责

练习

  1. 找出下面这段代码的两个问题(一个会报错,一个不会),并改对:
    def most_common(words: list[str]) -> str:
        counts = {}
        for w in words:
            counts[w] += 1
        return max(counts, key=counts.get)
  2. 给下面这个需求写一份给 AI 的规格(函数签名 + 文档字符串 + 至少三个例子,含边界情况): “把一个成绩列表转成等级列表”
  3. 用 AI 生成一个”判断字符串是否是合法的 IPv4 地址”的函数。然后:
    • 不看它的代码,先自己列出至少 6 个测试用例(含边界)
    • 用你的用例测它
    • 记录它有没有失败,失败在哪
  4. 把 L7 里你写的任意一个递归函数交给 AI,让它改写成循环版本。检查改写后是否等价,特别检查边界情况。
  5. 安装 ruff,对你 Lab 1 到 Lab 7 的代码跑一遍,看看它报了多少问题。挑三条你不理解的,查清楚为什么。
  6. 阅读 PEP 8 的 “A Foolish Consistency is the Hobgoblin of Little Minds” 一节,用一句话说明它在讲什么。

本讲的配套上机题在 Lab 8。从本周起 AI 政策进入 Level 1。