DeepSeek + Cursor:上下文才是生产力

开工前:先把环境调对,才能谈质量

上面提到的每一件事——精确引用、批量重构、规则生效——都建立在一个前提上:Cursor 能稳定地读到你的项目,并且按你的模型选择执行。这个前提没搭好,后面所有技巧都是空转。所以我把环境配置单独拎出来讲,它值得占掉开篇的一节。

项目初始化的三件小事

第一次在项目里打开 Cursor,我会先做三件事,它们花不了十分钟,但会影响之后几个月的使用体验。

.cursorignore:先做减法

在项目根目录建一个 .cursorignore,把构建产物、依赖、日志、本地数据全部排除。索引里混入这些内容,会让 @Codebase 的检索结果中大量出现重复代码,直接拉低生成质量。

# .cursorignore
node_modules/
dist/
build/
coverage/
.cache/
*.log
*.min.js
prisma/migrations/
test/fixtures/

索引健康:让它知道该往哪看

Cursor 的语义索引是后台异步建立的,项目很大时可能需要一段时间。索引没完成之前,@Codebase 只能给你零散的结果。一个简单的检验方法:在 Chat 里问”我们的统一响应包装是在哪个文件里实现的”,如果它能准确指出 common/interceptors/ 下的文件,索引就没问题;如果答非所问,通常说明索引还在建,或者被无关文件污染了。

一个能被模仿的模块

这是最容易被忽略的一件事。Cursor 生成新代码时,最依赖的是”有没有一个可模仿的参照物”。如果你的项目里连一个结构完整、分层清晰、注释到位的模块都没有,AI 就没有可信的范式可用,只能按通用风格发挥。所以我的建议是:在正式大量使用 Cursor 之前,先把一个模块(比如最基础的用户模块)整理到”你愿意让它当范例”的程度。这一份投入,后面会被反复复用。

先把模型接进来:Cursor 里用 DeepSeek 替换默认模型的正确姿势

Cursor 的默认模型足够聪明,但它的计费方式和调用额度决定了你不会真的拿它去跑一万行的批量改写。DeepSeek 在这件事上有一个很难被替代的位置:API 价格低到可以让「批量重构」这种原本不敢想的操作变成日常,同时它的推理模型在理解复杂契约、做代码审查这类需要”想清楚再动手”的任务上表现相当扎实。把两者接在同一个 Cursor 窗口里,按任务类型切换,是我目前用 Cursor 写后端代码时最舒服的搭配。

OpenAI 兼容接口:三步完成接入

DeepSeek 提供的是 OpenAI 兼容的 API,这意味着 Cursor 不需要任何专门适配就能用。在 Cursor 的 Settings → Models 里找到 OpenAI API Key 区域,打开 Override OpenAI Base URL,填入 DeepSeek 的地址,再用 DeepSeek 平台申请的 Key 覆盖原来的 OpenAI Key,然后点 Verify 验证。验证通过之后,在模型列表里手动添加 deepseek-chat 和 deepseek-reasoner 两个模型名,它们就会出现在 Cursor 的模型选择器里,可以直接用于 Chat 和 Composer。

# 接入参数(在 Cursor Settings → Models 中填写)
Base URL : https://api.deepseek.com/v1
API Key  : sk-xxxxxxxxxxxxxxxx(在 DeepSeek 开放平台创建)
Models   :
  - deepseek-chat        # 通用对话模型,便宜、快、上下文足够
  - deepseek-reasoner    # 推理模型,慢但擅长复杂推理与代码审查

# 验证方式
# 1. 填入后点 "Verify",出现绿色对勾即连通
# 2. 在 Chat 里 @Codebase 问一句 "这个项目的入口文件在哪"
#    能准确回答,说明索引与模型调用都正常

一个细节:deepseek-reasoner 在推理过程中会输出思考内容,Cursor 侧不一定能完整渲染它,表现为首字延迟明显变长。这不是卡住了,是它在想。做需要长链路推理的任务时,多等十几秒是正常代价。

为什么必须在”便宜快速”和”深度思考”之间来回切

很多人接上 DeepSeek 之后就一直用 deepseek-chat,理由是”反正够用”。短期看没问题,长期看你会在两类任务上反复吃亏。

第一类是机械改写。改命名、把 callback 换成 async、把字符串拼接换成模板字符串——这类任务的正确答案几乎是唯一确定的,模型只需要足够快的吞吐量和对局部代码的准确理解。deepseek-chat 做这个又快又便宜,用推理模型属于拿大炮打蚊子,除了让等待时间变成三倍,没有任何收益。

第二类是契约设计。设计一个订单状态机、判断软删除和唯一索引之间的冲突、审查一段并发扣库存的代码——这类任务的难点不在写代码,在于想清楚边界条件。这时 deepseek-reasoner 的优势非常明显,它会先把”如果两个请求同时到达”这种问题在脑子里过一遍,再落笔。用 deepseek-chat 去做,它往往会给你一段看起来正确、但在并发下必然翻车的代码。

我在 Cursor 里的实际习惯是:Composer 里的多文件小改动走 deepseek-chat,需要读多个文件才能判断的改动手动切到 deepseek-reasoner;代码审查固定用 deepseek-reasoner,因为审查的本质是找别人的错,而找错这件事,思考深度直接决定召回率。

切换模型本身也是一项工程决策

Cursor 允许你为不同的任务设置不同的默认模型,这个能力值得认真用。如果你把”生成函数”和”批量重构”都塞给同一个模型,你的体验一定会是其中一边不满意。

我见过一种很典型的抱怨:”DeepSeek 写代码不如某某模型。” 追问下去,对方用 deepseek-chat 去做架构级的重构,一次让它改三十个文件,然后得到一堆无法编译的代码。这不是模型的问题,是任务和模型错配。把任务按”确定性”排序:确定性高的交给快模型,确定性低的交给慢模型,是使用 Cursor 时最基本的一条纪律,但真正做到的人不多。

第一段:自然语言生成函数,为什么你的第一句话就决定了结局

绝大多数人用 Cursor 生成函数的方式是这样的:打开 Composer,输入”帮我写一个用户注册接口”,回车,等待。然后拿到一段用了 FastAPI 而你项目用的是 Express、用了 UUID 而你项目主键是自增 ID、抛出 ValueError 而你项目返回 {code: 40001, msg: ...} 的代码。删掉重写,时间比手写还长。

这里要说一句可能有点刺耳的话:AI 不是写不出合格的注册接口,是它根本不知道什么叫”合格”。你的项目有一套自己的合格标准,而你没有告诉它。

直接说”帮我写一个用户注册接口”会得到什么

把这句话丢给任何一个模型,它只能按通用最佳实践作答。通用最佳实践包括:密码用 bcrypt 哈希、邮箱要唯一、返回 201、用 JWT。这些都没错,但错位的地方往往在更细的层面。

  • 框架错位:你的项目用 NestJS,它给你 Express 的写法。它不知道你项目的 Controller 层只做参数校验,业务逻辑在 Service,仓储在 Repository。
  • 错误码错位:它抛 HTTP 500 加一段英文 message,而你的前端靠 code 字段做 i18n。
  • 校验错位:它用 if (!email) return ... 手写校验,而你项目统一用 zod schema,校验逻辑和 Swagger 文档共用同一份定义。
  • 风格错位:它写了一个 40 行的 try/catch/finally,而你的项目依赖一个全局异常过滤器。

这四种错位叠加起来,生成出来的代码虽然”能跑”,但它和你的项目之间隔着一层翻译。你花在调整风格上的时间,往往超过从零手写的时间。这就是为什么很多人用了几周 Cursor 之后,结论是”AI 写 CRUD 是噱头”。

正确的交代方式:四件事必须说清楚

我把交代拆成四块:数据模型、接口契约、错误码约定、项目既有风格范例。前三个是”要做什么”,第四个是”做成什么样”。四个都给全,生成质量会发生质变。

数据模型:AI 需要的是定义,不是描述

不要写”用户表有 id、邮箱、密码、创建时间”。要把真实的 schema 贴进去,或者用 @ 引用 schema 文件。字段类型、是否可空、默认值、索引、外键关系,这些信息决定了生成的 SQL 和校验逻辑是否正确。

model User {
  id           BigInt   @id @default(autoincrement())
  email        String   @unique @db.VarChar(255)
  passwordHash String   @map("password_hash") @db.VarChar(100)
  nickname     String?  @db.VarChar(32)
  status       Int      @default(1)  // 1=正常 2=禁用 3=待激活
  tenantId     BigInt   @map("tenant_id")
  createdAt    DateTime @default(now()) @map("created_at")
  updatedAt    DateTime @updatedAt @map("updated_at")
  deletedAt    DateTime? @map("deleted_at")  // 软删除标记

  @@index([tenantId, email])
  @@map("users")
}

注意 deletedAt 这个字段。如果 schema 里有它,你就必须告诉 AI 唯一索引应该怎么处理——email 上的 unique 加上软删除,会导致”用户注销后同一个邮箱无法重新注册”。这个坑 AI 不会主动发现,除非你在提示词里点出来。

接口契约:输入输出要写成协议

接口契约包括路由、方法、请求体结构、状态码、响应体包装格式。我习惯把这三段写成一段紧凑的文本,比长段落有效得多。

POST /api/v1/auth/register
Body   : { email: string, password: string(8-72), nickname?: string(<=32) }
Header : X-Tenant-Id: number(必填,由网关注入)
200    : { code: 0, data: { userId: string, token: string, expiresIn: number } }
Error  : { code: number, msg: string, requestId: string }

错误码约定:AI 最爱在这里自由发挥

错误码是整段提示词里最容易出问题的部分。如果你不指定,模型会自己发明一套,或者更糟——混用 HTTP 状态码和业务码。我会把项目里已经存在的错误码枚举引用给它,并要求"不得新增错误码,只能用已有的"。

// @errors.ts(已有文件,作为唯一来源)
export const ErrorCode = {
  OK: 0,
  PARAM_INVALID: 40001,      // 参数校验失败
  EMAIL_EXISTS: 40002,       // 邮箱已注册
  TENANT_INVALID: 40003,     // 租户不存在或已停用
  PASSWORD_WEAK: 40004,      // 密码强度不足
  RATE_LIMITED: 42901,       // 请求过于频繁
  INTERNAL: 50000,
} as const;

项目既有风格:给一篇范文胜过写十条要求

这是我认为最被低估的一点。与其写"请遵循项目代码风格",不如图中直接 @ 一个已经写好的同类接口,比如 @src/modules/auth/service/login.service.ts,然后说"严格模仿它的分层结构、日志格式和异常处理方式"。

模型在模仿具体样例上的能力,远强于遵循抽象规范。这是我在使用 Cursor 过程中反复验证的结论:一条"请保持风格一致"的价值,约等于零;一个可读的样板文件,价值约等于全部。

上下文引用:@ 文件、@ 代码库,决定生成质量的关键按钮

Cursor 的 @ 引用不是一个便利功能,它是模型能理解你项目的唯一通道。没有它,Cursor 只是一台接了 API 的通用代码生成器;有了它,Cursor 才成为你项目的一部分。

@File 与 @Codebase 的分工

@File 是精确指定。你明确知道哪个文件相关时用它,比如 schema、错误码定义、同模块的相邻文件。@Codebase 是语义检索,让 Cursor 在索引里自己找相关文件,适合你不确定相关代码在哪的情况,比如"这个项目的软删除是在哪里统一处理的"。

我的经验是:生成具体代码时用 @File,调研和排查时用 @Codebase。因为它们的行为不同——@File 是确定性的输入,@Codebase 是让模型自己猜相关性,后者在生成任务里会引入噪声。一个真实的例子:有次我用 @Codebase 生成订单接口,模型检索到了两个不同版本的 OrderService(一个在旧目录里遗留着),于是把两套逻辑混在了一起。改成精确 @File 之后就正常了。

引用多少个文件才合适

太少,模型靠猜;太多,注意力被稀释,而且会触发截断。我的经验值是 3 到 6 个:数据模型 1 个、错误码或常量 1 个、同类实现 1 个、相邻的测试文件 1 个,再加上待修改的目标文件。超过 8 个引用时,命中率开始下降,尤其是当引用的文件都很长时。

Cursor 索引做好了,质量就稳了一半

顺手提一句工程上的事:@Codebase 依赖 Cursor 在后台建的向量索引。项目里如果有 dist/、node_modules/、生成出来的 *.d.ts、旧的迁移文件目录,一定要在 .cursorignore 里排除。否则检索非常容易被构建产物里的重复代码带偏,这是"同样提示词,别人效果好我效果差"的常见原因之一。

一个完整示例:从数据结构到 API 到测试

下面是我实际在用的三段式生成流程。核心思想是:不要一次让 AI 从零写到测试,而是分三步,每一步都以已确认的上一步结果为输入。这样每一步的上下文都是"已知且正确"的,误差不会累积。

第一步:先生成 Service 层,不碰路由

Service 层是业务规则所在,是最需要人类审阅的地方。先让它单独产出,人工确认后,再让它基于这个 Service 生成 Controller 和测试。这样 Controller 和测试都建立在已确认的 Service 之上。

函数生成提示词模板
你是本项目后端开发。现在实现「用户注册」的 Service 层方法。

【项目上下文】
@prisma/schema.prisma      —— 数据模型唯一来源
@src/common/errors.ts      —— 错误码唯一来源,禁止新增错误码
@src/modules/auth/service/login.service.ts —— 同类实现,严格模仿其分层、
                              日志格式(logger.child)、异常抛出方式

【技术栈】
NestJS 10 + Prisma 5 + PostgreSQL,TypeScript strict 模式,
统一返回包装由全局拦截器完成,Service 内不要拼 code/msg。

【接口契约】
register(input: { email: string; password: string; nickname?: string; tenantId: bigint })
  => Promise<{ userId: bigint; token: string; expiresIn: number }>

【业务规则(必须逐条落实)】
1. email 统一 trim + toLowerCase 后入库;查询时同样处理
2. 唯一性判断必须忽略已软删除记录:where { email, deletedAt: null }
3. 密码 bcrypt(cost=12) 哈希;长度 8-72 字节(bcrypt 截断上限)
4. 校验 tenantId 对应的租户存在且 status=1,否则抛 TENANT_INVALID
5. token 由 JwtService 签发,payload 含 sub/tenantId,7 天有效期
6. 并发注册同一邮箱时,依赖数据库唯一约束兜底,
   捕获 P2002 并转换为 EMAIL_EXISTS,不要让异常穿透
7. 关键路径打日志,使用结构化字段 { tenantId, userId, emailHash }

【输出要求】
只输出 service 方法及其依赖的私有工具函数,不输出 Controller、不输出测试、
不新建文件。使用 zod 做入参校验,schema 定义放在同一文件顶部并导出。

开始前,先用 3 行以内列出你计划遵守的关键约束,再给代码。

注意最后一句"先列出关键约束再给代码"。这不是仪式感:让模型先复述约束,能把漏读的规则暴露出来。我大概有三成的次数,会在它复述的那三行里发现它漏掉了软删除相关的唯一性判断。

第二步:基于已确认的 Service 生成 Controller

Service 确认之后,让 Cursor 只做路由层的事。提示词里引用刚生成的 Service,强调"Controller 不做业务判断,只做参数解析和调用"。

@src/modules/auth/auth.controller.ts 是同类实现的范例。
基于刚才确认的 UserAuthService.register,补全 Controller 方法。

要求:
- 路由 POST /api/v1/auth/register,从请求头取 X-Tenant-Id
- 入参校验复用 service 导出的 zod schema,在 Pipe 层完成
- 不要写 try/catch,异常交给全局过滤器
- 不要写返回包装,全局拦截器负责
- 加 Swagger 装饰器,描述与契约一致
- 全文件不超过 60 行

只输出新增方法,不输出整个文件。

第三步:让 AI 写测试,但只写它自己想不到的用例

这是一个反直觉的做法。让 AI 写测试,它最擅长写"顺利路径"的测试,而这恰恰是价值最低的部分。顺利路径你有集成测试兜底,真正值钱的是边界条件。

所以我的第三条提示词会明确要求它"只写容易出错的用例",并给出方向:重复邮箱、软删除后的邮箱复用、并发注册、密码超长、租户不存在、tenantId 缺失。这样生成的测试,覆盖率不如全量生成,但发现问题的概率高得多。

为 UserAuthService.register 写单元测试,使用项目的 test 工具链
(参考 @src/modules/auth/service/login.service.spec.ts)。

只写以下场景,每个场景一个 describe,不要写顺利路径:
1. 已存在未软删除用户,返回 EMAIL_EXISTS
2. 已存在但已软删除用户,允许重新注册
3. 两个并发请求同邮箱,其中一个必须得到 EMAIL_EXISTS 而非 500
4. 密码 73 字节,必须被拒绝(PASSWORD_WEAK)
5. tenantId 指向已停用租户,返回 TENANT_INVALID
6. email 首尾含空格 + 大写,应被规范化后入库

mock 只 mock PrismaClient 与 JwtService,不要 mock 业务方法,
否则测不到真实逻辑。断言重点关注 errorCode,而不是 message 文案。

三步走完之后,从 schema 到 API 到测试一共 4 个文件,改动量约 300 行,人工只需要在 Service 那一步认真读一遍。这就是"省样板"的真实收益——不是它写得多,而是它把注意力从"写"转移到了"审"。

第二段:批量重构,先做样板再做复制

批量重构是 Cursor 真正能拉开差距的场景,也是翻车率最高的场景。区别只在于你有没有把它当成一次工程操作,而不是一次对话。

哪些任务适合交给 AI 批量处理

判断标准只有一条:每个文件的改动是否遵循同一条可描述的规则。规则可描述,就能批量;规则依赖具体文件内容做判断,就必须一个个来。

适合:规则确定的机械改写

  • 命名规范统一:getUserInfo → getUserById,obj.user_list → obj.userList。规则纯粹,风险极低。
  • 错误处理统一:把散落的 res.status(500).json({error}) 统一改成 throw new AppError(code),交给全局过滤器。
  • 回调改 async:Node 的 callback 风格 API 换成 await,或者把 .then() 链改成 async/await。这类改动模式高度一致。
  • 字符串拼接改模板:'a' + b + 'c' → `a${b}c`,特别适合处理日志语句。
  • 旧库替换:moment → dayjs,request → axios,lodash.get → 可选链。API 映射表清晰时,效果很好。

不适合:需要逐文件判断的改动

反过来的例子:把"所有查询用户的地方都加上租户隔离"。听起来是一条规则,实际上每个查询的上下文都不一样——有的查询在事务里,有的走的是原生 SQL,有的已经通过中间件注入了租户。这种任务交给 AI 批量做,它会在 50 个文件里做出 50 套不同的决策。

标准作业流程:一个样板,人工确认,再复制

这是整篇文章里我最希望被记住的一个操作流程。它的核心是"把 AI 从一次性执行者变成可复制的模板"。

第一步:挑一个最有代表性的文件

不要挑最简单的,也不要挑最复杂的。挑一个"结构完整、覆盖大多数情况"的。如果项目里有 utils/、services/、controllers/ 三层,就按层各挑一个样板,而不是一个样板打通所有层。

第二步:在单个文件上做出完美样板

这一步要慢。在 Cursor 里打开这个文件,用 Cmd+K 或者 Composer 提出改动,然后人工逐行审阅,必要时来回三四轮。这个文件的目标不是"改完",而是"改对并且值得被模仿"。

第三步:把样板变成提示词里的范式

样板确认之后,不要直接说"按这个改其他的"。要把样板作为一个 @File 引用进去,并且明确写出"从样板中提炼出的规则"。这两件事看起来重复,实际上不一样:引用让模型看到具体形态,文字规则让模型知道哪些地方是必须保留的核心。

批量重构样板提示词
【任务】把 src/services/ 下其余文件里的回调写法改成 async/await。

【样板】
@src/services/user.service.ts 是已经人工确认过的样板,先仔细阅读,
提炼出它体现的改法。

【从样板中提炼的规则(必须逐条执行)】
1. 所有以 function(err, data) 为签名的调用,用 util.promisify 包装后 await
2. 错误处理:不再吞错误,统一 throw;只有样板中明确 try/finally
   释放资源的地方保留 try/finally
3. 串行依赖的 .then() 链改为顺序 await;互相独立的改为 Promise.all
4. 保留原有变量名与注释,不重命名、不删注释、不调整函数顺序
5. 每个函数改完,行为必须与原来完全等价(宁可少改,不可改错)

【范围】
本次只处理以下 5 个文件,逐个输出,每处理一个文件前先打印文件名:
- @src/services/order.service.ts
- @src/services/product.service.ts
- @src/services/cart.service.ts
- @src/services/coupon.service.ts
- @src/services/address.service.ts

【禁止】
- 不要顺手优化业务逻辑
- 不要新增依赖
- 不要修改接口签名
- 遇到无法确定等价性的地方,停止并标注 TODO 给我人工判断,
  不要猜测

处理完后,列出所有你标注的 TODO。

"遇到无法确定的地方停下来"这条要求非常重要。默认情况下模型倾向于把事情做完,宁可猜一个也不愿意交白卷。你必须明确告诉它:停下来是允许的,猜错是不允许的。

为什么不能一次让它改 50 个文件

这是我在 Cursor 上踩过最多的坑。原因有三个,都很具体。

上下文丢失:改到第三个文件就开始忘事

模型的上下文是有限的,而且随着对话变长,早期信息会被稀释。让它处理 50 个文件,处理到第 20 个时,你在第一条消息里定的规则已经基本被冲淡了。它开始"自由发挥",而你不会发现,因为你不会逐行审阅 50 个文件的 diff。

风格分叉:同一批改动出现三种写法

更隐蔽的问题是风格不统一。50 个文件里,前面 10 个用的是 await promisify(fn)(args),中间 20 个自己写了个 new Promise 包装,最后 20 个直接改成了 async 函数。每一种都能跑,但你的代码库从此多了一种新风格,而且没人说得清为什么。

改错无法回滚:一个 diff 里混了 50 个文件的改动

这是最致命的。如果你一次改 50 个文件,得到一个巨大的 diff。检查出第 37 个文件有问题,你怎么回滚?只能整个撤销,前 36 个正确的改动也一起没了。或者你手工逐个修复,那还不如一开始就分批做。

单文件重构 vs 批量重构的取舍表

维度 单文件重构(样板阶段) 批量重构(复制阶段)
适用批量 1 个文件,作为范式 3-8 个文件一批
人工投入 高,逐行审阅 中,看 diff 摘要 + 抽查
上下文压力 低,整个文件都在焦点内 随文件数增加快速上升
风格一致性 由人工把关,最可靠 依赖样板引用,中等可靠
回滚成本 单文件,撤销即可 按 batch 回滚,需提交粒度配合
模型选择建议 deepseek-reasoner deepseek-chat
典型失败模式 改得不够彻底,遗漏分支 风格分叉、规则遗忘、误改业务
建议节奏 一次做透,可来回多轮 每批提交一次,跑通再下一批

用 git 分批提交,让回滚成本降到最低

批量重构必须和 git 绑定使用,这一点没有商量的余地。我的操作习惯是这样的:

  1. 开工前先确保工作区干净,git status 无未提交改动。有的话先提交或 stash。
  2. 样板文件单独提交一次,commit message 写清楚"样板:callback → async(user.service)"。
  3. 之后每批 3-8 个文件,改完 git diff --stat 看一眼,跑一遍类型检查和测试,通过就提交。
  4. commit message 带上 batch 编号,比如 "refactor(async): order/product/cart batch-2"。
  5. 任何一批出问题,git revert <commit> 单独回滚,不影响其他批次。

还有一个技巧:批量重构时把 Cursor 的改动限制在未提交范围内,提交之后再改下一批。这样 git diff 永远只显示当前这一批的改动,你可以用 git diff 代替 Cursor 的 diff 视图来审阅,速度更快,也更不容易漏。

三种任务的 AI 介入程度对比

任务类型 典型例子 适合交给 AI 的程度 人工必须做的事
样板型 CRUD 基础增删改查、分页列表 高,80% 可由 AI 生成 审审数据模型映射与错误码
机械重构 改名、async 化、换库 高,但必须分批 定样板、跑测试、按批提交
一致性改造 统一错误处理、统一日志 中,先做样板再复制 判断边界用例是否覆盖
业务规则实现 库存扣减、优惠叠加、状态机 低,AI 只适合起草 设计边界条件,验证一致性
并发与事务 抢购、余额变更、幂等 很低,AI 用于审查而非编写 自己写核心逻辑,AI 找漏洞
数据迁移脚本 历史数据订正 低,生成草稿后可读性差 必须在影子库演练

重构之后怎么验证:三个不能省的检查

批量重构最容易骗人的地方,是类型检查和 lint 都通过了,测试也全绿,然后上线出了问题。原因是这三道检查只验证"代码还合法",不验证"行为还等价"。所以在提交每一批之前,我还会做三件不那么标准化的事。

第一件:看 diff 里的删除行

重构的 diff 里,新增行往往一眼就能看出对不对,真正需要警惕的是删除行。callback 改 async 时,被删掉的 if (err) return callback(err) 如果在新代码里没有对应的 throw,错误就被静默吞掉了。这类问题不会让测试变红,只会让线上日志变少。我的习惯是每批改完,专门过一遍所有以 - 开头的行,问一句"这行承载的信息去哪了"。

第二件:对比调用次数

把串行 .then() 改成 Promise.all 是最常见的"优化型改错"。原来两条查询是顺序的,改成并行之后,如果第二条查询依赖第一条的结果(比如先查用户再查用户的订单),就会拿到 undefined。AI 判断独立性的能力并不可靠,尤其是当变量名不体现依赖关系时。所以凡是被改成 Promise.all 的地方,我都会逐处人工确认一次。

第三件:给关键路径留一条日志对比

对订单、支付这类关键路径,我会在重构前后各跑一次相同的测试用例,把结构调整后的日志输出并排对比一遍。字段是否齐全、requestId 是否还带上、顺序是否有变化,这三件事一眼就能看出问题。这个动作比重读代码快得多,也更容易发现问题。

什么情况下应该放弃批量,退回手工

有三个信号出现时,我会立刻停止批量,改成一个个手工改。一是同一个规则在三个文件上给出了三种解释,说明这条规则本身不够清楚,继续批量只会把模糊放大。二是开始出现"改动难以用一句话描述"的情况,说明任务已经超出了机械改写的范围。三是测试从绿变红且原因不明确,说明等价性假设已经不成立,这时候必须回到单文件粒度重新建立信心。知道什么时候停手,比知道怎么批量更重要。

第三段:自定义工作流,把规则沉淀成文件

前两段讲的都是"这一次怎么做好"。第三段讲的是"怎么让它下次自动做对"。答案只有一个:把约束写进项目里的规则文件,让 Cursor 每次对话都自带这些约束。

.cursorrules 该写什么

规则文件的价值不在于写得多,而在于写得可以被告知给一个陌生人执行。判断标准很朴素:一个新来的实习生读完你的规则文件,能不能不看代码就写出符合风格的 PR?能,就合格。

技术栈与版本

这一块要写具体。不是"使用 Node.js",而是"Node 20 + NestJS 10 + Prisma 5 + PostgreSQL 15,包管理用 pnpm,不使用 yarn 和 npm"。版本号能避免模型按老版本的 API 写代码,这在 Prisma 和 Next.js 这种版本差异大的生态里尤其重要。

目录约定

把目录结构和每一层的职责写清楚。模型不知道你的 modules/ 下面是不是还有 domain/、infra/,你不写,它就会按自己的直觉放文件。写清楚之后,生成的代码大概率直接落在正确的位置。

错误处理规范

这是最值得写的一块。包括:错误码从哪里来、在哪一层抛出、在哪一层捕获、日志怎么打、对外 message 怎么处理(通常不暴露内部细节,只给 requestId)。

禁止使用的库与写法

负面清单比正面要求更有效。"禁止使用 moment,统一用 dayjs"、"禁止在 Service 里拼 HTTP 响应"、"禁止使用 any,必要时用 unknown 加类型守卫"、"禁止在循环里 await,批量操作用 Promise.all 或批量接口"。每一条都对应一个你曾经被坑过的具体场景。

代码风格

这一块尽量写能被 lint 覆盖的部分交给 lint,规则文件里只写 lint 管不了的,比如"函数超过 40 行必须拆分"、"Service 方法内的注释必须说明业务背景而非代码行为"。

.cursorrules 模板
# 项目:知了商城后端
# 适用于所有 .ts 文件,除非另有说明

## 技术栈
- Node 20 / NestJS 10 / Prisma 5 / PostgreSQL 15 / pnpm
- TypeScript strict: true,禁止 any
- 测试:Jest + 内置的 TestingModule,不引入其他测试库

## 目录约定
src/
  modules/<domain>/          每个业务域一个目录
    .controller.ts    只做路由、参数解析、调用 service
    .service.ts       业务逻辑与事务边界
    .repository.ts    所有 Prisma 调用只能出现在这里
    dto/                      zod schema,import 到 controller 与 swagger
  common/errors.ts            错误码枚举,唯一来源,禁止新增
  common/filters/             全局异常过滤器
  common/interceptors/        统一响应包装
- 新文件只能放在 modules// 下,禁止新建顶层目录

## 错误处理
- 业务异常一律 throw new AppError(ErrorCode.XXX),禁止 res.status
- 不在 service 内 catch 后重新抛出,让它冒到全局过滤器
- 只有 P2002/P2025 等 Prisma 已知错误允许在 repository 层转换
- 对外消息不含堆栈、SQL、内部 ID;日志含 requestId
- 日志使用 logger.child({ requestId, tenantId, userId }) 后记录

## 数据
- 所有查询默认过滤 deletedAt: null,软删除通过中间件或显式 where 实现
- 涉及多表写入必须用 prisma.$transaction,禁止分步提交
- 金额统一用 Decimal(18,2),禁止用 number 或 float
- 时间统一存 UTC,展示层转换用 dayjs

## 禁止
- moment、lodash(用原生或 es-toolkit)、request、axios 之外的 HTTP 库
- 在 controller 写业务判断、在 service 写 SQL
- 循环内单条查询,一律 findMany + in
- 新增错误码、新增依赖、修改 tsconfig

## 风格
- 函数不超过 40 行,超过必须拆分私有方法
- 导出函数必须有 JSDoc 说明业务背景,不描述实现
- 命名:get/list/create/update/delete 前缀,布尔用 is/has/can
- 注释用中文,标识符用英文

## 生成代码时
- 动手前先列出你将遵守的 3 条最关键约束
- 不确定的地方停下来标注 TODO,不要猜
- 修改已有文件时保持最小 diff,不顺手格式化无关行

写规则最常见的错误:把愿望写成规则

我见过大量项目的 .cursorrules 第一行是"写出高质量、可维护、可读性强的代码"。这句话对模型的影响是零。它不是坏人,它只是不知道"高质量"对你意味着什么。

可执行约束和愿望清单的区别,在于能不能被判定违反。对比一下:

  • 愿望:"错误处理要健壮" | 约束:"业务异常必须 throw AppError,禁止在 service 中 catch 后返回 null"
  • 愿望:"注意性能" | 约束:"禁止在循环内发起数据库查询,批量用 findMany + in"
  • 愿望:"保持代码风格一致" | 约束:"函数不超过 40 行,布尔变量以 is/has/can 开头"
  • 愿望:"写清晰的注释" | 约束:"导出函数必须有 JSDoc,说明业务背景而非实现"

右边这种写法有一个额外的好处:你可以让 AI 扮演审查者,用它来检查自己写出的代码是否违反了右栏。这是左栏做不到的——你没法让 AI 检查"是否健壮"。

规则文件的分层:全局、项目、单文件

Cursor 支持多层规则。我的用法是:全局规则放和个人习惯有关的东西,项目规则放技术栈和架构约定,单文件规则放局部特例。

全局规则

放在用户级的规则文件里,所有项目共享。内容包括:代码注释语言、commit message 风格、你个人的命名偏好、拒绝输出的格式(比如"不要输出解释性段落,只给代码和关键说明")。这部分应保持很短,因为它在每个项目里都会被注入,越长噪声越大。

项目规则

放在项目根目录的规则文件里,进版本库,团队共享。内容就是上面那个模板。它是三层里最厚的。

单文件规则

放在特定目录下,只对那个范围的代码生效。典型场景:src/legacy/ 下的旧代码不允许被重构,只允许最小修复;src/modules/payment/ 下的所有金额计算必须经过统一的 Money 工具类。单文件规则解决的是"项目规则在某个角落不适用"的问题,别把它当成第二个项目规则来用。

CRUD 的真实状况:省掉的是样板,省不掉的是判断

到这里要说一些不太好听的实话。前面讲得再好,也改变不了一个事实:AI 在 CRUD 上最大的价值是省掉打字,而不是省掉思考。

AI 能省掉的与省不掉的

一个典型的用户接口,120 行代码里大概有 90 行是样板:路由声明、参数解析、DTO 定义、Swagger 注解、分页参数处理、统一的响应格式、基础的异常转换。这部分交给 Cursor,效果非常好,而且越用越准,因为你的规则文件会越来越贴你的项目。

剩下 30 行是业务规则和数据一致性。这部分的关键在于"知道该问什么问题",而模型恰恰不知道你的业务里有哪些问题。它不知道你们公司的会员等级满减能不能和优惠券叠加,不知道订单取消后库存是立即返还还是延迟返还,不知道跨租户的 ID 是否允许访问。

真正的 CRUD 陷阱:AI 写得出来,但经常写错

下面这五类问题,是我在审阅 AI 生成的 CRUD 代码时遇到频率最高的。它们的共同特点是:代码看起来完全正确,单元测试也能过,但在生产环境里会出事。

字段校验:校验了,但校验得不对

AI 会写校验,但它的校验停留在"格式层面"。邮箱用正则、手机号 11 位、密码长度 8 位以上。它很少考虑业务层面的校验:手机号是否已被当前租户占用、昵称是否包含敏感词、金额是否允许为负数、状态值是否是合法枚举。

更细微的问题是"校验的时机"。很多校验依赖数据库当前状态,比如"每个用户最多 10 个收货地址"。这种校验必须放在事务里,或者在数据库层用约束保证,放在应用层的先查后写一定会超。

并发更新:先查后改是默认写法,也是默认漏洞

这是最典型的一类。AI 生成的更新逻辑几乎总是这样:查出来、改一下、存回去。这个模式在单线程下完美,在并发下丢更新。

// AI 常见写法:并发下会丢更新
const order = await prisma.order.findUnique({ where: { id } });
await prisma.order.update({
  where: { id },
  data: { version: order.version + 1, status: 'PAID' },
});

// 正确写法一:乐观锁
await prisma.order.updateMany({
  where: { id, version: expectedVersion },
  data: { version: { increment: 1 }, status: 'PAID' },
});  // 检查 count === 1,否则抛 CONFLICT

// 正确写法二:条件更新,把判断交给数据库
await prisma.order.updateMany({
  where: { id, status: 'PENDING' },
  data: { status: 'PAID' },
});  // 状态机迁移必须带前置状态条件

第二段那个 where: { id, status: 'PENDING' } 写法,是状态机实现里最重要的一行代码。它把"判断当前状态"和"更新状态"变成了一次原子操作。AI 几乎不会主动这么写,除非你在规则里明确要求。

软删除:所有查询都忘了带过滤条件

你在 schema 里加了 deletedAt,跟 AI 说"用软删除"。它会很听话地给 delete 方法加上 data: { deletedAt: new Date() }。然后列表查询、详情查询、计数查询、关联查询,全都没有 deletedAt: null。因为它只理解"删除是什么",不理解"软删除会影响所有读取"。

还有三个衍生问题更隐蔽:唯一索引冲突(注销的用户占着邮箱)、聚合统计把已删除数据算进去、关联查询的 include 带出了已删除的子记录。这四件事必须写进规则文件,并且用测试锁住。

权限颗粒度:只校验了"登录",没校验"是不是你的"

AI 生成的接口默认加一个 JWT 校验中间件,然后就结束了。它不会自动加上"这条订单属于当前用户"的判断。于是在你的系统里,任何登录用户都可以通过改 URL 里的 id 查看别人的订单——这就是最经典的 IDOR 漏洞。

更细的颗粒度问题它更不会主动考虑:同一个租户下,普通员工能不能看主管的报销单;财务能不能改金额;已经审批通过的能不能撤回。这些权限规则是你的业务资产,AI 只能实现,不能发明。所以我的做法是:在规则文件里写死"任何带 id 的资源接口,必须校验资源所属 tenantId 与当前 tenantId 一致;跨用户访问必须显式声明 admin 权限",然后让审查 prompt 专门检查这一条。

审计日志:AI 会写日志,但通常写错位置

让它加审计日志,它会在 controller 方法末尾加一行 logger.info('user updated')。这条日志的问题很多:它不带变更前后的值、不带操作者、在事务外(事务回滚了但它已经记了)、在抛异常时会漏掉。

正确的审计日志必须与业务写入在同一个事务内,记录 who / what / before / after / requestId / ip。这类"跨切面的正确性"是 AI 的弱项,因为它需要同时改动事务边界、数据结构和日志规范。

大多数人做错的地方:把 Cursor 当成代码生成器

这是我最想说的一节。绝大多数人用 Cursor 效率平平,根本原因不是提示词不好,而是把它当成"更聪明的代码补全"。他们打开 Composer,写一句话,拿结果,复制粘贴,继续写下一句话。整个过程是"请求-响应"式的,AI 在每一次交互里都是一个陌生人。

我认为正确的姿势是反过来的:Cursor 不是代码生成器,是一个需要入职培训的新同事。

这个比喻能解释很多实际现象。你不会指望一个刚入职的同事第一天就写出符合团队风格的代码,你会先给他看架构文档、让他读已有的模块、告诉他哪些库是不能用的、让他先在小需求上练手。而这些动作,对应到 Cursor 里恰好就是:.cursorrules、@File 引用、.cursorignore、先做样板再加批量。

由此可以推出一个和主流说法相反的结论:用好 Cursor 的大部分工作发生在敲提示词之前。规则文件、索引清理、样板文件、目录结构——这些一次性投入决定了你后面每一次交互的起点高度。我看到大量的人在讨论"哪个模型更强"、"提示词怎么写得更好",但很少有人认真去写一份能被执行的项目规则。这是投入产出比最失衡的地方。

第二个反常识的观点:不要追求一次生成就完美。我几乎从不指望第一个版本就是最终版本,我期望的是"第一个版本让我能快速指出哪里不对"。这是因为给 AI 指出错误,比向它描述需求容易得多。所以好提示词的标志不是长,而是能在较短篇幅里把"错误可以被识别"的锚点给出来——比如错误码枚举、返回格式、样板文件。这三个东西本质上是"判分标准",有了判分标准,迭代速度会快很多。

第三个:让 AI 少做,往往比让它多做更有效。很多人喜欢让 AI"顺便优化一下"、"顺便加个日志"、"顺便把测试补上"。这些"顺便"是质量事故的主要来源。我现在的提示词里有一半篇幅是"禁止":不要改无关代码、不要顺手重构、不要新增依赖、不要猜测。约束越明确,返工越少。

质量闸门:让 AI 先过一遍,再让人过一遍

AI 生成的代码必须经过闸门。闸门的作用不是拦住 AI,而是把人的注意力集中到只有人才能判断的地方。我把闸门分成四层,从快到慢依次执行。

类型检查与编译:最快的一道闸

TypeScript 项目里,tsc --noEmit 是最便宜的检查。AI 生成的代码有相当比例会在类型上报错,尤其是自定义类型、泛型推断、以及跨模块引用。这一层必须在每次生成之后立刻跑,不要积累。

我建议在项目里配置一个快速的检查脚本,让 Cursor 在 Agent 模式下能直接调用。当它能自己跑命令并看到报错,它会自动进入修复循环,这部分几乎不需要人工介入。真正需要人工的,是"通过类型检查但语义错误"的那些问题。

lint 与格式:把风格争议交给工具

不要让 AI 在提示词里学你的缩进和引号,交给 ESLint 和 Prettier。规则文件里我只写 lint 覆盖不到的约定。一个实用技巧:让 AI 生成代码前先读一下 ESLint 配置,它就会避免生成那些必然被 lint 报错的写法,减少无意义的自纠循环。

单元测试:用测试锁住 AI 最常犯的错

测试在这里的作用有点特殊。它不是用来验证"代码实现了需求",而是用来防止 AI 在后续修改中把已经正确的逻辑改错。所以我会把前面提到的五类陷阱各写一个测试:软删除后再注册、并发更新返回冲突、跨租户访问被拒、金额精度、状态机非法迁移。这五个测试通过,这组 CRUD 才算安全。

对于批量重构,测试的用法更简单:重构前跑一遍拿到基线,每批改完再跑一遍,绿了才提交。这一条能把大部分"看起来等价其实不等价"的错误挡在提交之前。

让 AI 扮演审查者:用 prompt 审自己的代码

这是四层闸门里最有价值的一层,也最少人用。做法很简单:新开一个 Chat,把刚生成的 diff 贴进去,配上下面这个审查提示词,模型切换成 deepseek-reasoner。

关键在于审查和生成必须分开。同一个上下文里让 AI 审自己刚写的东西,它会倾向于认为自己的写法是对的——这不是恶意,是它已经沿着那条思路走过一遍了。新开对话、清空偏好、只给代码和规则,它才会真的像一个外部审查者。

代码审查提示词模板

你是这个代码库的资深审查者,即将 review 一个 PR。
你没有参与这段代码的编写,请以挑错为目的阅读。

【项目规则】
@.cursorrules (以下是硬约束,任何违反都必须在报告中列出)

【待审代码】
<diff>
...粘贴 git diff 内容...
</diff>

【审查重点(按优先级)】
P0 安全:
  - 是否有接口只校验登录、未校验资源归属(IDOR)
  - 是否有 SQL 注入、越权、敏感信息进入响应或日志
P0 数据一致性:
  - 是否存在先查后改的并发丢更新
  - 多表写入是否在事务内;事务内是否有外部 HTTP 调用
  - 软删除过滤是否覆盖所有读取路径
P1 业务正确性:
  - 状态迁移是否带前置状态条件
  - 金额是否使用 Decimal;是否可能为负
  - 幂等:重复请求是否会产生重复副作用
P2 规范:
  - 是否违反 .cursorrules 中的禁止项
  - 错误码是否新增了不存在的值
  - 是否在 controller 写了业务逻辑

【输出格式】
1. 一句话结论:可合并 / 需修改 / 需重做
2. 问题列表,每条包含:
   - 级别 P0/P1/P2
   - 位置:文件:行号
   - 问题:具体是什么错,在什么条件下会出错(写出触发顺序)
   - 建议:给出可直接替换的代码片段
3. 如果没有发现问题,明确说"未发现问题",
   并列出你检查过但未发现问题的项,不要为了凑数编造问题。

只审查 diff 中的改动及其直接依赖,不评论无关的历史代码。

最后那句"不要为了凑数编造问题"很必要。否则它会给你一堆"建议增加注释"、"考虑使用更清晰的变量名"这种噪声,把你的注意力从真正的 P0 问题上引开。

四层闸门的执行顺序与分工

总结一下我实际执行的顺序:生成 → tsc(自动)→ lint(自动)→ 单测(自动)→ AI 审查(半自动)→ 人工看 P0 清单(人工)→ 提交。前面三层几乎零成本,第四层需要花一两分钟,人工只需看 AI 给出的 P0 条目以及它说"未发现问题"的那些项。

这个流程的关键在于:人工的时间不应该花在找拼写错误、格式错误、类型错误上,那些交给机器。人工只回答机器答不了的问题:"这个业务规则写对了吗?这个权限模型符合我们的设计吗?" 当你把注意力都放在这些问题上时,用 Cursor 写 CRUD 才会真的变快——不是打字快了,是判断快准了。

FAQ

DeepSeek 接入 Cursor 后,为什么 Chat 里提问会报 400 或超时?

先检查 base_url 是否带上了 /v1,Cursor 走的是 OpenAI 兼容路由,少这一段多数情况下会 404 或 400。其次是模型名必须与平台完全一致。此外,deepseek-reasoner 首字延迟明显,Chat 列表里的一些功能(比如自动生成对话标题)调用它时容易超时,建议把默认模型设为 deepseek-chat,只在需要推理时手动切换。

提示词里塞了太多文件,效果反而变差怎么办?

先做减法。把引用控制在 3-6 个,优先保留 schema、错误码、同类实现样板这三类。其次检查 .cursorignore,把 dist/、node_modules/、历史迁移文件排除掉。如果仍然不好,改用"垂直切片"策略:只引用一个完整的模块作为上下文,让 Cursor 按这个模块的形态生成新模块,比引用一堆零散文件效果稳定得多。

批量重构时,AI 把不该改的业务逻辑也顺手改了怎么办?

这是最常见的失控。处理办法是把"禁止项"写进提示词,并且明确要求"改动必须是行为等价的",遇到无法确定等价的地方停下来标 TODO。提交前用 git diff 逐块看,重点看那些没有被规则覆盖的行。如果已经改错,按 batch 粒度 git revert,比逐个手工修复快得多。

.cursorrules 写了几百行,但 AI 好像根本不遵守?

规则太长会被稀释,这是上下文的基本特性。我的经验是控制在一个屏幕左右,优先放负面清单和可判定的硬约束。同时把规则"拆层":根目录放技术栈和架构,特定目录放局部特例。另一个常被忽略的点是,规则里的约束要在提示词里被再次提及一次——只有一两句的那种,比如"遵循 @.cursorrules,特别是错误码不得新增"。重复一次,命中率会明显提高。

让 AI 审自己的代码,它总说没问题,怎么破?

三个办法叠加。第一,换新对话,不要在上文里审;第二,切换成推理模型;第三,把审查提示词从"帮我看看有没有问题"改成"按 P0/P1/P2 逐项检查,并列出你检查过的项"。清单式提问会把"没发现问题"从默认答案变成需要论证的结论。必要时反向提问:"如果这段代码在生产环境出事,最可能是什么原因?" 这个问题往往能挖出清单式提问漏掉的东西。

团队里每个人都在用 Cursor,怎么保证产出风格不打架?

核心是把个人偏好从提示词里拿出来,沉淀到版本库里。规则文件进 Git、ESLint 配置进 Git、样板文件进 Git,每个人用同一套上下文去生成,风格自然会收敛。另外建议把审查提示词也做成团队共享的模板,这样不同人用 Cursor 生成的代码,会经过同一套判分标准,比口头约定"大家注意风格"有效得多。

THE END
喜欢就支持一下吧
点赞35 分享
评论 抢沙发
头像-知了AI
欢迎您留下宝贵的见解!
提交
头像-知了AI

昵称

取消
昵称表情代码快捷回复

    暂无评论内容