CS1602计算导论
第 15 讲Part 6 Pythonic 与现代实践AI Level 2

AI 辅助编程(二):从提问到项目

AI-Assisted Programming II: From Prompt to Project

L8 教的是"AI 写一个函数,你怎么审"。这一讲是"你要做一个项目,AI 怎么参与"——以及它在哪些地方一定会让你失望。

本讲结束后你应当能

  • 把一句话需求拆成模块清单,并用接口先行的方式交给 AI
  • 说出 AI 在项目尺度上做得好和做不好的事
  • 用 AI 写测试、做评审、写文档,同时守住"人在环内"
  • 在团队协作中约定 AI 的使用方式
  • 判断什么时候必须自己写
本页目录

这一讲和 L8 的区别

L8 处理的是一个函数的尺度:AI 给你一段代码,你怎么判断它对不对。

这一讲跳到一个项目的尺度。当代码有几百上千行、分成多个模块、由两个人协作时,问题变了:

L8 的问题本讲的问题
这段代码对吗这个设计对吗
这个函数有没有边界 bug这些模块的接口合不合理
怎么验证一个返回值怎么验证一个系统
我和 AI 谁写这一行我和 AI 谁做哪一层

你正在做的团队项目(Lab 14 的简化版 NumPy)就是这个尺度。

从本周起 AI 政策进入 Level 2:完全开放。 但”开放”不等于”交出判断”。


一、从写函数到做项目

先拆,再问

假设需求是一句话:“做一个简化版 NumPy。”

直接把这句话丢给 AI,你会得到几百行代码——读不完,也就验证不了。L8 说过:读不完的代码等于没验证。

正确的第一步是你自己拆:

Array 类
├── 数据存储        ← 内部表示,最重要的决定
├── 形状管理        ← shape / ndim / size / reshape / transpose
├── 索引            ← __getitem__ / __setitem__
├── 逐元素运算      ← __add__ / __sub__ / __mul__ / __truediv__
├── 归约            ← sum / mean / max / min(带 axis)
└── 表示            ← __repr__ / __eq__

模块级函数
├── 构造            ← zeros / ones / arange
└── 矩阵乘          ← dot

这一步是你的工作,不是 AI 的。 因为拆分方式决定了整个程序的结构,而 AI 不知道你的取舍——你打算做几维?性能重要吗?要不要支持广播?

拆完之后,你有了一堆可以独立提问的小任务。

接口先行

拆完之后,不要描述功能,写出签名:

class Array:
    def __init__(self, data: list) -> None:
        """从嵌套列表构造。data 必须是规整的(每行等长),否则抛 ValueError。
        内部用扁平列表 self._data 加 self._shape 元组存储。
        """

    def reshape(self, *shape: int) -> "Array":
        """返回一个新 Array,形状改为 shape,数据顺序按行优先。
        元素总数必须匹配,否则抛 ValueError。
        不修改原对象。

        >>> Array([[1,2],[3,4]]).reshape(4).shape
        (4,)
        """

这几行说清了:输入类型、输出类型、错误条件、内部表示、是否原地修改、一个例子。AI 拿到这个,几乎不可能理解错。

对比一下你如果只说”写个 reshape”——它得猜内部表示、猜要不要原地改、猜形状不匹配时怎么办。猜错了你还得重来。

这就是 L4 说类型提示的第三个用途、L8 说”签名是可执行的规格”的兑现。

给它上下文

项目尺度上,AI 需要知道你已经有什么。

一个有效的做法是,每次提问时附上:

  • 相关的已有代码(不是全部,是这个任务会用到的部分)
  • 你的约定:命名风格、错误处理方式、内部表示
  • 已有的测试,让它知道什么算对
这是我们项目里 Array 的构造和内部表示:

class Array:
    def __init__(self, data):
        self._data = [...]      # 扁平列表,行优先
        self._shape = (...)     # 形状元组

现在请实现 transpose(),要求:
- 返回新对象,不改原来的
- 只需支持一维和二维
- 一维时直接返回自身的副本
- 用我们现有的 _data / _shape 表示

现有测试(应该通过):
    assert Array([[1,2,3],[4,5,6]]).transpose().shape == (3, 2)
    assert Array([1,2,3]).transpose().shape == (3,)

一次一小步,每步验证

这是 L7 分治思想在协作上的应用:

  1. 问一个函数
  2. 读一遍——不理解就问它解释,或者要求换个写法
  3. 测一遍——用你自己准备的用例
  4. 通过了再问下一个

二、AI 做得好和做不好的事

做得好

任务为什么适合
样板代码机械、重复、模式固定
格式转换输入输出都明确
写测试用例能穷举常见情况(但边界要你补)
写文档字符串代码在眼前,描述它是它擅长的
解释报错见过海量同类错误
重构“把这段改成用推导式”这类明确指令
查 API 用法比翻文档快(但要核实)

做不好

一、性能与复杂度判断。

这是最值得警惕的一类。AI 倾向于给出能跑的方案,而不是能用的方案。

# 一个 AI 很可能给你的 dot 实现
def dot_naive(a: list[list[float]], b: list[list[float]]) -> list[list[float]]:
    n, m, p = len(a), len(b), len(b[0])
    result = []
    for i in range(n):
        row = []
        for j in range(p):
            s = 0.0
            for k in range(m):
                s += a[i][k] * b[k][j]
            row.append(s)
        result.append(row)
    return result

import time
size = 60
A = [[1.0] * size for _ in range(size)]
B = [[1.0] * size for _ in range(size)]

t = time.time()
dot_naive(A, B)
elapsed = time.time() - t

print(f"{size}×{size} 矩阵相乘耗时 {elapsed:.4f} 秒")
print("这是 O(n³):矩阵边长翻倍,耗时变 8 倍")
print(f"推算 600×600 大约需要 {elapsed * 1000:.1f} 秒")

代码完全正确。但如果你的项目要处理 1000×1000 的矩阵,这个实现是不可用的——而 AI 不会主动告诉你。

它不知道你的 n 有多大。 只有你知道。

import time

# 同一个问题,两种数据结构
def count_dups_list(items: list[int]) -> int:
    seen: list[int] = []
    count = 0
    for x in items:
        if x in seen:        # O(n) 查找
            count += 1
        else:
            seen.append(x)
    return count

def count_dups_set(items: list[int]) -> int:
    seen: set[int] = set()
    count = 0
    for x in items:
        if x in seen:        # O(1) 查找
            count += 1
        else:
            seen.add(x)
    return count

data = list(range(8000)) * 2
t = time.time(); count_dups_list(data); a = time.time() - t
t = time.time(); count_dups_set(data);  b = time.time() - t
print(f"用列表: {a:.4f} 秒")
print(f"用集合: {b:.5f} 秒")
print(f"相差 {a / b:.0f} 倍。两段代码结果完全一样。")

二、跨模块的一致性。

你分十次问了十个函数,每次它都给出局部合理的代码。但合起来可能:

  • 命名风格不统一(get_shape vs shape vs getShape)
  • 错误处理方式不一致(有的返回 None,有的抛异常)
  • 重复实现了同一个辅助函数三遍
  • 对内部表示的假设不同

AI 每次只看到你给它的那一段上下文。 全局一致性只能靠你。

三、你的项目特有的约定。

“我们项目里所有形状都用元组表示”、“所有错误都抛 ArrayError 的子类”、“内部索引从 0 开始但对外从 1 开始”——这些它不可能知道,除非你每次都说。

四、判断需求本身对不对。

如果你的需求有问题,AI 会忠实地实现一个有问题的东西。它不会说”你确定要这样吗”。


三、用 AI 做质量工作

让它写测试

这是 AI 很擅长的事——但有一条铁律。

为下面这个函数写 pytest 测试:

def reshape(self, *shape: int) -> "Array":
    """..."""

要求覆盖:正常情况、元素数不匹配、一维转二维、二维转一维、
空数组、单元素数组。

它能很快给你一批用例。但是:

让它做代码评审

把 L8 的 PEP 8 检查表交给它:

按 PEP 8 审查这段代码,重点看:
- 命名是否符合 snake_case / PascalCase
- 有没有遮蔽内置名
- 函数是否过长、嵌套是否过深
- 类型提示是否完整
- 有没有可变默认参数

只报告问题,不要重写。

“只报告问题,不要重写” 这一句很重要。否则它会直接给你一个改好的版本,而你失去了逐条判断的机会。

也别忘了工具:ruff 比 AI 更快、更确定、免费,而且不会幻觉。能用工具查的,别用 AI 查。

让它写文档

为这个类写 README 的「用法」一节,包含:
- 三个由简到繁的例子
- 每个例子的输出
- 一段说明内部表示的注意事项

文档是 AI 的强项:代码在眼前,把它翻译成散文是它做得最好的事之一。

但要核实例子的输出——它经常把输出”想”出来而不是算出来。


四、团队协作中的 AI

你们两人一组做项目。加上 AI,实际是三方协作。

先约定,再动手

在写第一行代码之前,两个人应该先定下:

事项为什么要先定
内部表示中途改要推倒重来
命名风格事后统一改名很痛苦
错误处理约定抛异常还是返回 None,必须一致
代码格式化工具black + ruff,配置进项目
测试怎么组织文件放哪、怎么运行

把这些写进 README 的开头。每次向 AI 提问时把这段贴上去,一致性问题就少一大半。

谁对 AI 生成的代码负责

提交它的人。 不是写提示词的人,不是 AI。

这意味着:如果你把 AI 给的代码合并进项目,你就要能解释它。批改时问到那一段、期末考到那个概念,“那是 AI 写的”不是答案。

依赖与许可证

AI 有时会建议引入某个第三方库。引入之前问三件事:

  1. 真的需要吗? 标准库能不能做?团队项目还明确禁止用 NumPy 实现
  2. 它的许可证是什么? 有些许可证对使用方式有要求
  3. 它维护得怎么样? 上次更新是三年前的库,风险很高

五、什么时候必须自己写

不是出于诚信要求,而是出于效果——这些场合 AI 帮不上,或者帮了反而更慢:

一、你还不理解的东西。

如果你不懂矩阵乘法在做什么,AI 给你的 dot 你也审不了。先弄懂,再让它写。 否则你只是在积累自己无法维护的代码。

二、需要在多个方案里做取舍的地方。

“用嵌套列表还是扁平列表加步长”——这取决于你更在乎什么。AI 能列出两种方案的优缺点(这很有用),但选哪个是你的判断。

三、调试。

AI 看不到你的运行时状态。给它一段代码和一个报错,它会猜;而你有调试器(L8),能看到每个变量的实际值。

用调试器定位问题,用 AI 理解问题。 顺序不能反。

四、最后一公里的正确性。

代码 90% 对的时候,剩下 10% 往往是你的具体场景才有的边界。这部分 AI 帮不上,因为它不知道你的场景。


六、这门课结束之后

工具会继续变。你在这门课学的具体语法,五年后可能有一半过时了。

但有些东西不会:

拆解问题的能力。 把一个模糊的需求变成一组清晰的小任务——这是 L7 分治思想的日常版本,也是使用任何工具的前提。

判断对错的能力。 知道该测什么、边界在哪、什么情况下会崩。AI 越强,这个能力越值钱,因为它能生成的东西越多,你需要判断的就越多。

估算代价的能力。 看到嵌套循环就想到 n²,看到 in 就问是列表还是集合。这是 L11 教的,它和语言无关。

说清楚一件事的能力。 无论是写文档、写提示词、还是跟队友解释——能把一件事说明白的人,用什么工具都比别人快。


小结

  • 项目尺度的问题不是”这段代码对吗”,而是”这个设计对吗”
  • 先自己拆,拆到一个任务你能一口气读完为止——拆分是你的工作
  • 接口先行:写出签名、类型、错误条件、例子,再让 AI 填实现
  • 每次提问附上相关代码、项目约定、已有测试
  • 一次一小步,每步验证。要一大坨然后”看起来能跑”是最危险的模式
  • AI 做得好:样板、格式转换、写测试、写文档、解释报错、重构
  • AI 做不好:性能与复杂度判断、跨模块一致性、你的项目约定、判断需求本身
  • 提问时说清数据规模,命中率会明显提高
  • AI 生成的测试不能验证 AI 生成的代码——边界情况必须你先列
  • 让它评审时说”只报告问题,不要重写”;能用 ruff 查的别问 AI
  • 团队先约定内部表示、命名、错误处理,再动手
  • 谁提交,谁负责。“那是 AI 写的”不是答案
  • 必须自己写的:你不懂的、需要取舍的、调试、最后一公里的边界

练习

  1. 拿你团队项目里还没实现的一个方法,写出完整的接口规格(签名 + 文档字符串 + 三个例子含边界),不写实现。把这份规格交给 AI,评价它给的实现。
  2. 让 AI 实现 dot(),然后测量它在 100×100 和 200×200 时的耗时。验证是不是 O(n³)。如果是,问它”如何优化”,评价它的建议。
  3. 先自己列出 reshape() 的至少 8 个边界情况,再让 AI 生成测试用例,对比它漏了哪些。
  4. 把你和队友各自写的一个模块交给 AI,让它只报告不一致的地方(命名、错误处理、风格)。评价它找出的问题里有几条是真的。
  5. 找一段你队友写的代码,问 TA 一个问题:“这一行为什么这么写?“记录这次对话——如果对方答不上来,你们发现了什么?
  6. 写一份你们团队的 AI 使用约定,不超过半页,至少包含:什么可以问、提交前必须做什么、谁负责。

本讲的配套上机题在 Lab 15。AI 政策进入 Level 2。