接手 AI 写的代码,先别急着骂
古董级程序员,大厂出来后一直在创业公司,现在仍在一线写代码、做产品、和 AI 编程工具较劲。更完整的更新会放在微信公众号「字与码」:工作经历、新技术观察、AI 开发实践,以及这些年踩过的坑,都会陆续写在那里。
《AI时代的程序员修养》前面两篇讲了测试和可观测性。测试让结果可复现,观测让系统出事时可定位。
这一篇讲更现实的场景:你接手了一段 AI 写出来的代码,已经能跑,甚至已经有人在用。但它像一团线,函数很长,边界不清,异常处理玄学,改一处容易带出三处问题。
这时先别急着骂。骂没有产出。真正有产出的是把它拆开、钉住、清理、重构,让它从“能演示的原型”变成“能继续维护的代码”。
不要一上来大改
接手烂代码,最危险的动作是立刻重写。
AI 生成代码尤其如此。它可能有很多问题,但也可能藏着一些已经被用户验证过的业务细节。你觉得多余的分支,可能是在绕某个真实线上坑;你觉得奇怪的字段,可能被前端或者脚本依赖;你觉得可以合并的函数,可能实际上承担了不同副作用。
所以第一步不是重构,而是冻结行为。
冻结行为的意思是:先确认它现在到底做了什么,再决定哪些要保留、哪些要删除、哪些要重写。
一个简单原则:
没有测试保护的重构,不叫重构,叫赌博。
先跑起来,拿到基线
接手 AI 代码,先做三件事。
能启动
能测试
能复现关键路径
不要急着整理目录,也不要急着改命名。先让项目在本地跑起来,记录启动命令、环境变量、依赖版本和最小数据集。
例如一个报告生成服务,可以先建立这样的基线:
uv sync
uv run pytest
uv run uvicorn app.main:app --host 0.0.0.0 --port 8000
再准备一组最小请求:
curl -X POST http://localhost:8000/reports \
-H 'content-type: application/json' \
-d '{"tool_id":"demo_tool","query":"最近一周数据","format":"summary"}'
如果这些都跑不起来,先补运行文档和 fixture。一个不能稳定启动的项目,谈不上重构。
AI 生成代码常见的问题,是本地能跑一次,但依赖隐含在全局环境里。接手时要把这些隐含条件显式化:.env.example、测试数据、启动脚本、数据库迁移、外部服务 mock。
用回归测试钉住现状
AI 代码不可靠,不能只靠读。
读代码只能告诉你作者想表达什么,测试才能告诉你系统实际承诺什么。
接手阶段的测试不追求优雅,先追求保护关键行为。比如报告服务至少要有这些回归测试:
def test_create_report_returns_queued(client):
response = client.post(
"/reports",
json={"tool_id": "demo_tool", "query": "hello", "format": "summary"},
)
assert response.status_code == 200
assert response.json()["status"] == "queued"
assert "report_id" in response.json()
def test_create_report_rejects_unknown_tool(client):
response = client.post(
"/reports",
json={"tool_id": "missing", "query": "hello", "format": "summary"},
)
assert response.status_code == 400
assert response.json()["error_code"] == "TOOL_NOT_FOUND"
这类测试不一定完美,但它能先保护入口行为。
然后再补 bug 样例、边界样例和失败样例:
空 query 怎么处理
超长 query 怎么处理
重复提交是否幂等
第三方超时是否进入重试
权限不足是否返回稳定错误码
结果保存失败是否会重复扣费
接手 AI 代码时,测试不是为了证明你写得好,而是为了防止你改坏现有行为。
画出模块图
很多 AI 代码难维护,不是因为语法差,而是因为边界糊。
一个函数里可能同时做了参数校验、权限检查、数据库查询、第三方调用、结果格式化、扣费、日志、埋点。它能跑,但任何改动都可能牵一大片。
接手时可以先画一个粗模块图:
API Layer
-> Auth / Permission
-> Use Case / Application Service
-> Domain Rules
-> Repository
-> Provider Client
-> Queue / Worker
-> Billing / Audit
这不是为了套架构名词,而是为了判断职责在哪里。
更具体一点:
API 层:只处理 HTTP、schema、错误响应。
Use Case:编排业务流程,例如创建报告、取消报告。
Domain Rules:纯业务规则,例如状态机、金额、权限策略。
Repository:数据库读写,不包含业务决策。
Provider Client:外部接口调用,包含超时、重试、错误映射。
Worker:异步执行,不直接依赖 HTTP 上下文。
Audit/Billing:关键副作用,必须可追溯、可幂等。
AI 生成代码常把这些揉成一个大函数。重构的目标不是“分层好看”,而是让每个改动只影响它该影响的地方。
看依赖方向
判断代码是否难维护,一个很实用的方法是看依赖方向。
理想情况下,核心业务规则应该少依赖外部东西。它可以被单测直接调用,不需要启动数据库、HTTP 服务或第三方 SDK。
例如:
def can_cancel_report(status: str, owner_id: str, actor_id: str) -> bool:
if actor_id != owner_id:
return False
return status in {"queued", "running"}
这类函数最好是纯函数。输入明确,输出明确,没有数据库,没有网络,没有时间读取,没有随机数。
反过来,下面这种代码就难测:
async def cancel_report(report_id: str, actor_id: str):
report = await db.get(report_id)
user = await auth.current_user()
if user.id != report.owner_id:
await logger.warning("permission denied")
return False
await provider.cancel(report.provider_job_id)
await db.update(report_id, {"status": "cancelled"})
await send_notification(user.id)
return True
它混合了数据库、认证、权限规则、外部调用、状态变更和通知。要测试一个简单权限规则,也要拖进一堆依赖。
接手时可以先把纯规则抽出来:
def decide_cancel_report(report: Report, actor: Actor) -> CancelDecision:
if actor.id != report.owner_id:
return CancelDecision(allowed=False, reason="not_owner")
if report.status not in {"queued", "running"}:
return CancelDecision(allowed=False, reason="invalid_status")
return CancelDecision(allowed=True, reason="ok")
外层再处理副作用:
decision = decide_cancel_report(report, actor)
if not decision.allowed:
raise PermissionDenied(decision.reason)
await provider.cancel(report.provider_job_id)
await repository.mark_cancelled(report.id)
await audit.record(...)
这一步通常是 AI 代码从“能跑”变成“能测”的关键。
圈复杂度和函数长度不是玄学
重构时可以用一些很朴素的指标做雷达。
函数太长、分支太多、嵌套太深,通常意味着它承担了太多职责。
圈复杂度可以粗略理解为:代码里有多少条独立路径。if、for、while、except、逻辑运算都会增加路径数量。路径越多,测试覆盖越难,修改风险越高。
比如:
def normalize_result(data, mode, user_type, fallback):
if mode == "summary":
if user_type == "vip":
...
else:
...
elif mode == "table":
if fallback:
...
else:
...
elif mode == "chart":
...
else:
...
这类函数不一定错,但很容易变成分支垃圾场。
接手 AI 代码时,可以先找这些信号:
单个函数超过 80-100 行
嵌套超过 3 层
一个函数里出现多种外部依赖
异常处理分散在多个层级
同一个状态字段被多个模块随手改
大量布尔参数控制行为
这些不是绝对规则,但足够帮你找风险点。
扇入扇出决定改动风险
还有两个很实用的概念:扇入和扇出。
扇入是“有多少地方调用我”。扇出是“我调用了多少东西”。
高扇入:很多地方依赖它,改它要小心。
高扇出:它依赖很多东西,通常职责过重。
高扇入 + 高扇出:危险核心,先补测试再动。
例如一个 execute_tool() 函数,被 API、worker、定时任务、脚本都调用,同时内部又访问数据库、缓存、第三方接口、扣费、日志、埋点。这种函数就是重构雷区。
处理方法不是一刀切重写,而是先加保护层:
1. 给现有 execute_tool 写黑盒回归测试。
2. 记录输入输出和关键副作用。
3. 把内部纯规则逐步抽出。
4. 把外部依赖包成接口。
5. 新逻辑走新路径,旧逻辑保留一段时间。
这就是小步重构。
先隔离副作用
AI 代码最大的问题之一,是副作用到处飞。
副作用包括:
写数据库
发 HTTP 请求
发消息队列
扣费
发通知
写文件
读当前时间
读随机数
写审计
这些操作不是不能有,而是不能散落在任何地方。
一个可维护的流程,通常应该把“决策”和“执行副作用”分开:
def plan_report_execution(request: ReportRequest) -> ExecutionPlan:
# 纯决策:选择 provider、检查模式、计算预算、生成步骤
return ExecutionPlan(...)
async def execute_report_plan(plan: ExecutionPlan, deps: Dependencies) -> ReportResult:
# 副作用集中在这里:调用 provider、写数据库、写审计
...
这样做有两个好处。
纯决策可以大量单测,跑得快,也不依赖外部环境。副作用集中后,可以统一处理超时、重试、幂等、错误映射和审计。
给 AI 的提示词里要明确这一点:
请把业务决策写成纯函数,不要在纯函数里访问数据库、网络、当前时间或随机数。
外部副作用通过明确的 dependency interface 注入。
删代码比改代码更重要
AI 很擅长生成“看起来有用”的代码。
备用分支、未使用参数、过度抽象、未来可能用到的扩展点、重复的错误包装、没人调用的 helper,都可能堆在项目里。
接手时要敢删,但要有证据。
可以按这个顺序处理:
搜索调用点,确认没有引用。
跑测试和类型检查。
看构建产物或路由注册。
如果是 API 或脚本入口,确认没有外部调用。
删除后保留一次独立提交,方便回滚。
有些代码不能只靠本仓库搜索判断,例如被定时任务、外部脚本、用户 webhook 调用的入口。这类要先标记 deprecated,打日志观察一段时间,再删除。
删除代码的收益很直接:上下文变短,AI 更不容易被旧逻辑带偏,人 review 也更轻。
重构要按接缝切
重构不是把文件重新摆一遍。
好的重构会沿着接缝切。接缝就是系统里天然可以分开的地方:接口、数据库访问、外部 provider、队列任务、状态机、权限策略。
例如一个混乱的执行函数:
execute()
- parse input
- check permission
- select provider
- call provider
- normalize result
- save history
- bill user
- emit telemetry
可以先切成:
parse_execute_request()
authorize_execute()
select_provider()
call_provider()
normalize_provider_result()
record_execute_history()
charge_usage()
emit_execute_events()
注意,不是每个函数都要变成一个类。很多时候,几个清楚的函数就够了。
AI 生成代码经常过度类化。你让它“优化架构”,它可能立刻生成一堆 manager、handler、service、factory。接手时要压住这个冲动:先把边界切清楚,再决定是否需要抽象。
用 Strangler Fig 迁移大块逻辑
如果旧逻辑太大,不适合一次性重写,可以用 Strangler Fig 的思路:新逻辑一点点包住旧逻辑,逐步替换。
做法大概是:
保留旧入口。
在入口前加一个 router。
小流量或指定场景走新实现。
新旧结果并行比较一段时间。
确认一致后扩大范围。
确认稳定后删除旧实现。
伪代码:
async def execute(request: ExecuteRequest) -> ExecuteResult:
if should_use_new_executor(request):
return await new_executor.execute(request)
return await legacy_executor.execute(request)
如果是纯计算逻辑,还可以影子执行:
legacy_result = await legacy_executor.execute(request)
if shadow_enabled(request):
new_result = await new_executor.execute_shadow(request)
compare_and_record(legacy_result, new_result)
return legacy_result
这种方法适合大系统。它慢,但风险小。AI 代码越是已经上线,越不应该用“我重写一版肯定更好”的心态处理。
每次重构都要能解释收益
重构不是审美活动。
每次重构最好能说清楚收益:
把纯规则抽出来,单测从集成测试降为毫秒级。
把 provider client 封装起来,外部错误映射统一。
把扣费逻辑移到独立模块,避免重复扣费。
删除未使用分支,减少 400 行无效上下文。
拆开大函数,让权限、执行、记录三类失败可以分别定位。
如果说不清收益,就先别动。
工程里有一种常见错误:把“不喜欢的代码”都叫技术债。真正的技术债,是它会影响交付速度、稳定性、成本或团队理解。否则只是个人偏好。
接手 AI 代码尤其要克制。AI 可能写得不符合你的习惯,但只要边界清楚、测试充分、可观测性完整,也未必需要马上重写。
Code Review 要看行为,不只看风格
审查 AI 代码,不要只盯格式。
格式化工具能解决的东西,不值得人花太多时间。人应该看这些:
业务规则是否明确
失败场景是否完整
事务边界是否正确
副作用是否幂等
外部调用是否有超时
错误码是否稳定
日志指标是否足够
敏感数据是否泄露
测试是否覆盖关键路径
模块依赖是否反向
AI 生成代码最容易在“看起来合理”的地方出问题。比如它会捕获所有异常然后返回成功,会在数据库事务里调用慢接口,会把用户输入原样写进日志,会在重试时重复写历史。
这些才是 review 的重点。
一份接手 AI 代码的检查清单
可以把下面这份清单放进项目里:
1. 项目能否一条命令启动。
2. 是否有最小 fixture 和本地测试数据。
3. 核心入口是否有回归测试。
4. 已知 bug 是否有失败测试。
5. 大函数是否承担多个职责。
6. 核心规则是否可以脱离数据库和网络测试。
7. 外部副作用是否集中处理。
8. 事务内是否存在慢外部调用。
9. 重试是否幂等。
10. 错误码、日志、指标、Trace 是否完整。
11. 未使用代码是否可以删除。
12. 高扇入高扇出函数是否有保护测试。
13. 新旧实现是否需要灰度或影子比较。
这份清单不是一次性做完。它是接手顺序。
给 AI 的重构提示词
重构也可以让 AI 帮忙,但提示词要限制它的动作:
请先不要直接重写代码。
请审查这段代码,并输出:
1. 当前代码的主要职责列表。
2. 入口函数、外部副作用、数据库写入、网络调用和队列操作。
3. 高风险函数:函数长度、分支数量、扇入扇出、是否混合副作用。
4. 现有行为还缺少哪些回归测试。
5. 可以先抽出的纯业务规则。
6. 可以删除的未使用代码,但必须说明证据。
7. 一个小步重构计划,每一步都要求测试通过。
8. 哪些地方不能直接改,需要灰度或影子比较。
在我确认前,不要生成最终实现。
这段提示词的重点是让 AI 先分析,不要上来就“优化完成”。
AI 很适合做代码地图、调用链梳理、重复逻辑识别、测试样例生成。真正的边界判断、上线风险和取舍,仍然要由人负责。
原型不是罪,没人维护才是
很多 AI 代码一开始都是原型。
原型本身不是问题。工程里很多好东西都是从原型长出来的。问题是把原型直接当长期资产,却没有测试、没有边界、没有观测、没有删除无效代码。
接手 AI 代码时,最好的态度不是轻蔑,也不是迷信。
它能跑,说明它有价值。它乱,说明它还没完成工程化。
程序员要做的,就是把价值留下,把风险拆掉。
这件事听起来不酷,但很重要。因为未来很多系统都会带着 AI 生成的痕迹。会接手、会判断、会清理、会小步重构的人,会比只会抱怨“这代码谁写的”的人更有用。
微信公众号
欢迎关注「字与码」
如果这篇文章对你有用,也欢迎在微信里继续关注后续更新。
X / Twitter
关注 @ax2_zicode
更即时的技术观察、新文章提醒和一些短想法会发在 X 上。