AI 会写代码,但你要知道程序是什么
古董级程序员,大厂出来后一直在创业公司,现在仍在一线写代码、做产品、和 AI 编程工具较劲。更完整的更新会放在微信公众号「字与码」:工作经历、新技术观察、AI 开发实践,以及这些年踩过的坑,都会陆续写在那里。
《AI时代的程序员修养》这个专栏,不打算把重点放在“哪个 AI 工具更会补全代码”。工具会变,模型会变,但程序运行的基本规律没有变。写代码的人还是得看得懂系统怎么拆、问题怎么描述、生成出来的东西该怎么验收。
本文是第一篇,先从一句老话讲起:程序 = 算法 + 数据结构。这句话几十年前就写在教科书里,但在 AI 编程时代反而更重要。因为 AI 很擅长“把一句话变成一段代码”,但如果你没有把问题变成算法和数据结构,它生成的往往只是表面能跑的代码。
那句老话一点也没过时
很多人第一次听到“程序 = 算法 + 数据结构”,是在大学课堂、C 语言教材,或者 Pascal 时代的老书里。
这句话听起来很朴素,甚至有点老派。现在我们写的是 Web 服务、移动应用、推荐系统、数据平台、Agent 工具链,好像已经离“算法题”和“链表树图”很远了。
但只要把一个真实系统拆开,就会发现这句话还在底层撑着。
一个订单系统,算法不是排序题,而是价格计算、库存扣减、优惠叠加、风控判断、状态迁移。数据结构不是手写红黑树,而是订单表、状态机、索引、事件日志、缓存 key、消息体。
一个搜索系统,算法不是教科书里的二分查找,而是召回、过滤、排序、融合、去重、分页。数据结构不是抽象的数组和树,而是倒排索引、向量索引、文档结构、特征字段、评分结果。
一个 AI Agent,算法不是“让模型想一想”,而是任务拆解、工具选择、失败重试、上下文裁剪、结果校验。数据结构也不是模型内部的 token,而是消息队列、工具 schema、执行记录、状态快照、可恢复的中间结果。
所以“程序 = 算法 + 数据结构”不是在说程序员必须天天刷题。它说的是:程序不是一堆语句,而是数据如何被组织,规则如何作用在数据上。
AI 可以帮你写语句,但它不能替你决定系统里的数据应该长什么样。
先把这些老本行捡回来
只说“算法和数据结构很重要”,基本等于没说。对已经工作、又开始大量使用 AI 编程的人来说,下面这些东西值得重新过一遍。不是为了回去刷题,而是为了在看到需求时,脑子里能自然浮现出合适的结构。
| 知识点 | 工程里常见的样子 | 不懂时 AI 容易写错什么 |
|---|---|---|
| 数组 / 动态数组 | 分页结果、批处理缓冲区、时间序列点位 | 频繁头部插入删除,隐藏 O(n) 移动成本 |
| 哈希表 | 去重、缓存、索引、按 ID 聚合 | key 设计不稳定,内存无限增长,忽略冲突和过期 |
| 栈 | 表达式解析、递归展开、调用链、撤销操作 | 递归无深度保护,异常路径无法回滚 |
| 队列 / 优先队列 | 任务调度、消息消费、限速、延迟重试 | FIFO 和优先级混用,重试任务插回错误位置 |
| 树 | 评论、组织架构、权限继承、路由匹配 | 全树递归查询,深层节点性能和栈深度失控 |
| 图 | 依赖分析、工作流、推荐关系、服务调用链 | 不检测环,拓扑顺序错误,级联调用不可控 |
| 堆 | Top K、超时管理、定时任务、排行榜近似维护 | 全量排序代替局部选择,复杂度从 O(n log k) 变成 O(n log n) |
| 布隆过滤器 | 黑名单、缓存穿透防护、大规模去重预判 | 把“可能存在”当成“一定存在”,误判语义搞错 |
| LRU / LFU | 本地缓存、热点数据、连接池淘汰 | 只会用 dict,不会限制容量,也不会维护访问顺序 |
| 倒排索引 | 搜索、标签筛选、日志检索 | 每次请求全量扫描文本,再用字符串 contains 过滤 |
| 状态机 | 订单、任务、审批、支付、部署流水线 | 状态随便改,没有迁移约束,没有非法状态保护 |
这里暂时没有展开红黑树、B+ 树、跳表、LSM Tree 这些更偏底层实现的内容。它们当然重要,但第一步先把“会不会建模”这件事说清楚。等到后面讲数据库和高并发时,再谈 B+ 树为什么影响索引,LSM Tree 为什么影响写入和 compaction,跳表为什么常见于有序内存结构。
放到 AI 编程里,重点不是手写一个完美的堆。重点是看到“取最近 5 分钟内延迟最高的 100 个请求”时,马上意识到不能让 AI 简单写 sort(all_requests)。数据流可能很大,更合适的做法是流式处理,维护一个大小为 100 的最小堆;每来一个请求只和堆顶比较;内存复杂度是 O(k),不是 O(n)。
这些判断,才是专业知识在今天最有用的地方。
AI 最容易跳过的那一步
我们让 AI 写代码时,经常会这样说:
帮我写一个任务调度系统,支持创建任务、执行任务、查看状态。
模型通常很快能给出一套接口、一个数据库表、几段执行逻辑。看起来完整,但麻烦往往藏在后面。
任务有哪些状态?pending、running、success、failed 够不够?有没有 cancelled、timeout、retrying?一个任务失败后是立刻重试,还是等一段时间?重试次数放在哪里?任务执行到一半进程挂了,状态怎么恢复?同一个任务能不能并发跑两次?状态更新是否需要事务?
这些问题如果没有先想清楚,AI 写出来的代码就会默认选择一套隐含规则。隐含规则最危险,因为它们没有经过产品、工程、运维和测试的共同确认。
我现在更习惯先让 AI 拆问题,而不是直接写实现:
先不要写代码。
请把“任务调度系统”拆成:
1. 核心实体和字段
2. 状态机
3. 状态迁移规则
4. 并发执行约束
5. 失败与重试策略
6. 需要事务保护的操作
7. 可复现的测试样例
这段 prompt 不炫技,但非常管用。它把 AI 从“代码生成器”拉回“设计助手”。只有当数据结构和算法规则清楚之后,代码生成才有意义。
再往前推一步,任务调度系统最好先落一张状态机表:
| 当前状态 | 允许迁移到 | 触发条件 | 必须写入的字段 |
|---|---|---|---|
pending | running | worker 成功抢占任务 | locked_by, locked_at, attempt |
running | success | 执行完成 | finished_at, result_ref |
running | failed | 执行失败且不可重试 | finished_at, error_code, error_message |
running | retrying | 执行失败但仍可重试 | next_run_at, attempt, last_error |
retrying | pending | 到达下次执行时间 | 清理或更新抢占字段 |
running | timeout | 超过租约时间 | finished_at, timeout_reason |
| 任意终态 | 其他状态 | 不允许 | 记录非法迁移并拒绝 |
这张表比一段仓促生成的代码更有价值。状态机清楚之后,接口、数据库、worker、测试都可以围绕它长出来。没有这张表,AI 很可能在不同函数里直接写 task.status = "failed"、task.status = "success",最后系统靠字符串碰运气。
实现时也别把迁移规则散在业务代码里,集中写出来会踏实很多:
ALLOWED_TRANSITIONS = {
"pending": {"running"},
"running": {"success", "failed", "retrying", "timeout"},
"retrying": {"pending"},
"success": set(),
"failed": set(),
"timeout": set(),
}
def assert_transition(current: str, target: str) -> None:
if target not in ALLOWED_TRANSITIONS.get(current, set()):
raise ValueError(f"invalid transition: {current} -> {target}")
这段代码不难,AI 肯定写得出来。问题在于,你得在一开始就想到要让它写,而不是等线上出现“成功任务又被重试”之后再补。
先找数据,再谈代码
一个功能最开始不应该问“页面怎么写”,也不应该问“接口怎么写”,而应该问:这个功能里有哪些东西会被保存、传递、修改、聚合和丢弃。
比如做一个评论系统,表面需求很简单:用户可以发表评论,别人可以回复,可以点赞,可以删除。
如果直接写代码,很容易得到几个接口:
POST /comments
POST /comments/{id}/replies
POST /comments/{id}/likes
DELETE /comments/{id}
系统的骨架其实在数据里:
- 评论是树形结构,还是只允许一级回复?
- 删除评论是物理删除,还是显示“该评论已删除”?
- 点赞是否允许取消?
- 热门评论按点赞数、时间,还是综合权重排序?
- 被删除评论的回复是否保留?
- 评论审核前是否对外可见?
- 一个用户短时间内能发多少条?
这些问题本质上都是数据结构和状态规则。
如果你不懂这些,就不知道应该要求 AI 设计“邻接表”还是“路径枚举”,不知道要不要 deleted_at,不知道点赞是否需要唯一索引,也不知道热门排序应该预计算还是实时算。
AI 能告诉你几种方案,但它不会自动知道你的业务约束。程序员的价值就在这里:把模糊需求翻译成明确的数据结构。
以评论树为例,常见设计至少有三种。
| 方案 | 核心字段 | 优点 | 缺点 |
|---|---|---|---|
| 邻接表 | id, parent_id | 写入简单,适合浅层回复 | 查整棵树需要递归或多次查询 |
| 路径枚举 | id, path,如 /1/8/23/ | 查子树方便,排序也直观 | 移动节点成本高,path 需要维护 |
| 闭包表 | ancestor_id, descendant_id, depth | 查询祖先和子孙都快 | 写入复杂,数据量放大 |
如果产品只允许一级回复,邻接表就够了;如果允许多级讨论并经常展开整棵树,路径枚举更适合;如果权限继承、组织架构这类场景需要频繁查祖先和后代,闭包表可能更稳。
让 AI 写评论系统时,我会把这些约束直接塞进 prompt:
评论最多支持两层:评论和回复,不支持无限层级。
删除评论时做软删除,保留回复,但前端显示“原评论已删除”。
点赞表需要 (comment_id, user_id) 唯一约束,重复点赞返回幂等成功。
热门评论按 score 排序,score = like_count * 3 + reply_count - age_hours / 12。
列表接口必须分页,不能一次加载全部评论。
这段 prompt 背后其实是数据结构判断。你限定了树深,限定了删除语义,限定了唯一约束,限定了排序算法,限定了分页。这时 AI 生成的代码才会朝正确方向走。
算法不是炫技,是规则的顺序
很多工程代码里的算法并不复杂,但顺序非常重要。
以一个“用户升级会员”的流程为例,可能要做这些事:
- 校验支付结果
- 判断订单是否已经处理过
- 更新会员有效期
- 记录权益变更日志
- 发放优惠券
- 发送通知
- 触发统计事件
这些步骤每一步都能让 AI 写,但它们的顺序不能随便排。
先发通知再写数据库,失败时用户会看到错误信息。先发优惠券再确认幂等,重复回调可能多发。统计事件放在事务里,外部系统慢了会拖住主流程。会员有效期更新和权益日志不在同一个事务里,后续审计会对不上。
工程里的算法,很多时候不是高深的动态规划,而是把一组操作按正确顺序组织起来,并定义失败时如何收场。
给 AI 的指令写到这个层面,结果会稳很多:
实现会员升级流程。
约束:
- 支付回调可能重复,需要用订单号保证幂等。
- 会员有效期更新和权益变更日志必须在同一个数据库事务里。
- 优惠券发放、通知、统计事件不能阻塞主事务,写入 outbox 后由后台任务异步处理。
- 任意一步失败都要有明确状态,不能让订单停在不可解释状态。
这时 AI 写出来的代码质量会明显不同。因为你不是让它“自由发挥”,而是给了它算法的顺序、事务边界和失败规则。
再举一个更典型的算法例子:依赖任务调度。
很多后台系统都有“先跑 A,再跑 B 和 C,最后跑 D”的需求。表面看是任务队列,实际是一个有向无环图。这里用拓扑排序,比手搓一堆 if/else 靠谱得多。
如果你不提“拓扑排序”,AI 可能会写一个循环,不断扫描所有任务,找到前置任务完成的就执行。任务少时能跑,任务多时就是 O(V²) 级别的反复扫描,而且很容易漏掉环检测。
我会这样写 prompt:
任务之间存在依赖关系,请按 DAG 建模。
要求:
- 使用入度表和就绪队列做拓扑排序。
- 如果最终处理节点数小于总节点数,说明存在环,任务组必须失败。
- 每个任务只能在所有依赖成功后进入 ready。
- 任意依赖失败时,下游任务进入 skipped,不再执行。
- 输出每个任务的状态迁移记录,便于审计。
对应的核心伪代码并不复杂:
from collections import defaultdict, deque
def topo_order(nodes, edges):
graph = defaultdict(list)
indegree = {node: 0 for node in nodes}
for before, after in edges:
graph[before].append(after)
indegree[after] += 1
ready = deque([node for node, degree in indegree.items() if degree == 0])
ordered = []
while ready:
node = ready.popleft()
ordered.append(node)
for next_node in graph[node]:
indegree[next_node] -= 1
if indegree[next_node] == 0:
ready.append(next_node)
if len(ordered) != len(nodes):
raise ValueError("dependency cycle detected")
return ordered
这个算法的复杂度是 O(V + E),V 是任务数,E 是依赖边数。它的价值不是“面试会考”,而是能让你一眼看出 AI 写出来的依赖调度是不是在反复全量扫描,是不是没有环检测,是不是把失败下游任务错误地放进了 ready 队列。
复杂度仍然要算
AI 很容易写出看起来清晰、实际上很慢的代码。
最常见的是嵌套循环、逐条查询、无分页加载、全量扫描再过滤。小数据量下它能跑,线上数据一多就崩。
比如统计每个用户最近 30 天的订单金额,AI 可能写出这样的伪代码:
users = db.query("select * from users")
for user in users:
orders = db.query("select * from orders where user_id = ?", user.id)
total = sum(o.amount for o in orders if o.created_at >= start)
result.append({"user_id": user.id, "total": total})
这段代码逻辑没错,但复杂度和访问模式都不对。它把数据库当成一个慢速字典,做了 N+1 次查询,还把过滤放到应用层。
这类统计通常应该把聚合推到数据库:
select
user_id,
sum(amount) as total_amount
from orders
where created_at >= :start
group by user_id;
如果只关心部分用户,还要把用户集合限制进去:
select
o.user_id,
sum(o.amount) as total_amount
from orders o
join active_users u on u.id = o.user_id
where o.created_at >= :start
group by o.user_id;
这背后涉及三层复杂度判断。
第一,应用层循环的复杂度。N 个用户,每个用户一次查询,网络往返就是 O(N)。如果每次还拉回该用户所有订单,再在 Python 里过滤,总数据量越大越糟糕。
第二,数据库访问路径。orders(created_at, user_id) 和 orders(user_id, created_at) 是不同索引,适合的查询也不同。按时间范围扫全局订单,前者可能更好;按少量用户查最近订单,后者可能更好。
第三,结果是否需要预计算。如果每天都要查过去 30 天的滚动金额,可以维护日粒度汇总表,再用 30 行日汇总相加,而不是每次扫订单明细。
一个有经验的程序员看到这个需求,会先想数据规模:
- 用户有多少?
- 订单有多少?
- 是否只统计活跃用户?
- 是否有
orders(user_id, created_at)索引? - 能不能用聚合 SQL?
- 结果是否可以按天预计算?
这不是“性能优化阶段”才考虑的事,而是设计阶段就该考虑。因为数据结构一旦错了,后面再让 AI 改代码,只是在错误结构上打补丁。
同样的道理也适用于 Top K。
如果需求是“从 100 万条日志里找出耗时最高的 100 条”,让 AI 直接排序很常见:
slowest = sorted(logs, key=lambda item: item.duration_ms, reverse=True)[:100]
这会做 O(n log n) 排序。更合适的是维护大小为 k 的最小堆:
import heapq
def top_k_slowest(logs, k=100):
heap = []
for item in logs:
entry = (item.duration_ms, item.request_id, item)
if len(heap) < k:
heapq.heappush(heap, entry)
elif entry[0] > heap[0][0]:
heapq.heapreplace(heap, entry)
return [entry[2] for entry in sorted(heap, reverse=True)]
复杂度从 O(n log n) 变成 O(n log k),内存从 O(n) 变成 O(k)。k=100 时,这不是小优化,而是系统能不能在流式日志上工作的差别。
这类东西就是硬知识。平时未必需要手写堆,但什么时候该用堆、什么时候不能全量排序,心里要有数。
好 prompt 的底层是专业知识
很多人以为 prompt 写得好,是语言表达能力强。表达当然重要,但 prompt 背后真正撑着的,还是专业知识。
你知道进程和线程的区别,才会告诉 AI 哪些状态不能放进进程内存。你知道数据库事务,才会告诉 AI 哪些操作必须原子化。你知道队列和重试,才会告诉 AI 外部调用不能放在主事务里。你知道索引,才会发现 AI 生成的查询会全表扫描。
AI 覆盖了大量知识,但它不会替你做责任判断。它能给出“一个实现”,但你要判断这个实现是否适合当前系统。
所以 AI 时代的科班知识并没有贬值,只是用法变了。
以前你学数据结构,是为了自己写出更好的代码。现在你学数据结构,是为了知道该让 AI 生成怎样的数据模型,知道它生成错了该怎么改。
以前你学操作系统,是为了理解进程、内存、文件和网络。现在你学操作系统,是为了知道 AI 写出的后台任务为什么会阻塞,为什么日志会撑爆磁盘,为什么一个锁会拖垮整个服务。
以前你学数据库,是为了写 SQL 和设计表。现在你学数据库,是为了知道 AI 写的 ORM 代码是不是把事务开得太大,是不是每次请求都在做隐式 N+1 查询。
先别急着让它写代码
为了避免 AI 一上来就堆代码,我建议第一轮先让它输出四个东西。
第一是数据模型。包括实体、字段、唯一约束、索引、状态枚举、缓存 key、消息结构。没有数据模型,不进入编码。
第二是算法路径。说明核心流程使用什么算法或机制:状态机、拓扑排序、堆、哈希去重、倒排索引、批处理、滑动窗口,还是简单顺序流程。这里不需要故作复杂,但要明确。
第三是复杂度和规模假设。比如“单用户最多 1000 条评论”“每天订单 500 万”“任务依赖边最多 10 万”“接口 P95 要小于 200ms”。规模假设不同,方案就不同。
第四是验收样例。至少包含正常路径、边界路径、失败路径、并发路径。没有样例,AI 写出的代码只能靠肉眼看。
一个可以直接复用的 prompt 是:
先不要写实现代码。
请基于下面需求输出工程设计草案:
1. 数据模型:实体、字段、唯一约束、索引、状态枚举。
2. 核心算法:说明用到的数据结构和算法,给出复杂度。
3. 规模假设:估计数据量、并发量、热点和可能的瓶颈。
4. 失败路径:列出异常、重试、幂等、超时、取消、回滚。
5. 验收样例:至少 8 个测试用例,包含边界和并发。
输出后等待我确认,不要直接写代码。
这个 prompt 的价值不在格式,而在于它先把设计摊开。设计摊开了,程序员才能审;如果直接生成 1000 行代码,很多错误会藏得很深。
一个更靠谱的工作顺序
我现在用 AI 写功能,越来越倾向于按这个顺序走:
先让 AI 复述问题,但不写代码。重点看它有没有理解用户、数据、状态和边界。
然后让 AI 输出数据结构。包括数据库表、JSON schema、状态枚举、索引、缓存 key、消息体。这里如果写不清楚,后面代码一定会乱。
再让 AI 输出算法流程。不要只写 happy path,要写失败路径、重试路径、并发路径、取消路径。
接着让 AI 写测试样例。不是泛泛地说“测试成功和失败”,而是给出具体输入、预期状态、数据库变化和外部副作用。
最后才让 AI 写代码。
这个顺序看起来慢,其实更快。因为 AI 写代码太快了,真正浪费时间的是写完之后发现结构错了。
回到这篇文章
这篇文章没有停在“程序 = 算法 + 数据结构”这句老话上,而是把它拆成了更具体的清单:数组、哈希表、队列、堆、树、图、倒排索引、布隆过滤器、LRU、状态机这些知识,在真实系统里分别会变成什么问题。
文章里也给了几个可落地的例子:任务状态机、评论树建模、依赖任务的拓扑排序、订单统计的聚合 SQL、Top K 的堆实现。这些例子不是为了炫算法,而是为了说明一件事:AI 能写代码,但程序员要知道应该要求它使用什么结构、什么算法、什么复杂度。
它还没有展开进程通信、模块交互、数据库事务、高并发这些内容。那些会放到后面的文章里。第一篇的任务只是立一个地基:AI 时代的程序员不能只会验收界面,更要能验收程序结构。
如果这个地基不稳,后面所有“让 AI 写一个系统”的尝试,都会变成在沙子上堆代码。
微信公众号
欢迎关注「字与码」
如果这篇文章对你有用,也欢迎在微信里继续关注后续更新。
X / Twitter
关注 @ax2_zicode
更即时的技术观察、新文章提醒和一些短想法会发在 X 上。