· 从两份规格文件开始

从两份规格文件开始

4116 字
21 分钟
从两份规格文件开始
StarryLin/Geek · 摘要

本文提出以规格文件为唯一真源驱动AI协作的开发流程,涵盖清单生成、纯函数排序规则、拖拽三段式实现、spec纠偏优于改代码的实践,以及npm空安装、容器host绑定等部署踩坑经验。

下面按这次任务看板的实现记录,整理规格、任务拆分和验收时需要注意的地方。文中的 AI 指负责执行代码修改的助手。

一、起点:两份规格文件#

一切的起点是两份 Markdown:

  • specs/feature-task-board.md —— 数据模型(Task / TaskStatus / Priority / User)、5 个 API 接口、功能需求清单、4 条业务规则
  • specs/ui-task-board.md —— 页面结构、6 个组件的详细规格、交互规范、颜色 / 字体 / 间距 / 圆角四套设计系统

这两份文件的价值不在于”写得很全”,而在于它把判断权从 AI 手里收了回来。

举个具体的例子:UI 规格里写着「卡片宽度 280px、圆角 8px、内边距 16px、hover 时 box-shadow: 0 4px 12px rgba(0,0,0,0.1)、过渡 all 0.2s ease」,还写着「优先级颜色:critical red-500 / high orange-500 / medium yellow-500 / low green-500」。这些数字不是建议,是验收口径。AI 不需要猜,我也不需要在代码评审时争论”这里是不是应该用 12px 的圆角”。

反过来说,规格里没写的,AI 就会自由发挥 —— 这正是后面踩坑的根源。


二、操作:把”实现一个看板”变成”勾选 24 个动作”#

2.1 第一步不是写代码,是生成清单#

我给 AI 的指令是这样的:

请阅读以下 speckit 文件:
@Files specs/feature-task-board.md
@Files specs/ui-task-board.md
根据 Speckit 的内容,帮我生成一份详细的开发任务清单(SpeckitList),要求:
1. 按照依赖关系排序(先做的排最前面)
2. 每个任务标注优先级(P0/P1/P2,与 Speckit 中的优先级对应)
3. 每个任务有明确的验收标准
4. 每个任务预估工作量(以"AI 生成轮数"为单位)
5. 用 Markdown Checkbox 格式

这 5 条要求,每一条都在解决一个具体问题:

要求解决什么问题
依赖排序AI 不会先写组件再补类型,避免返工
优先级时间不够时,知道砍哪些不心疼
验收标准任务”完成”的定义是客观的,不是 AI 说了算
工作量(AI 轮数)能估算一轮对话能推几个任务,避免一次喂太多
Checkbox进度可视化,中断后能无缝续上

最终得到 specs/speckit-task-list.md:24 个任务、6 个阶段、总计约 21.5 轮。阶段划分是:

阶段 0 数据模型与设计系统(无依赖) T1-T2
阶段 1 后端 API 层 T3-T7
阶段 2 UI 基础组件 T8-T11
阶段 3 核心交互(CRUD + 拖拽) T12-T16
阶段 4 筛选与搜索 T17-T18
阶段 5 体验优化(Loading/空态/响应式) T19-T21
阶段 6 可选项增强 T22-T24

这里有个容易被忽略的细节:阶段 0 里我把”数据模型”和”设计系统 Token”并列放在最前面,二者互不依赖。原因是它们分别是后续所有逻辑代码和样式代码的共同上游。类型定不下来,API 层写不下去;色板定不下来,每个组件都要单独调颜色。

2.2 第二步:一次只推一个任务#

分步执行的指令模板长这样:

我正在执行 SpeckitList 中的任务 T1:数据模型。
请参考:@Files specs/feature-task-board.md(数据模型章节)
创建 src/types/task.ts,严格按照 Speckit 中的字段定义。
完成后我会将该任务标记为已完成。

三个要素缺一不可:

  1. 引用具体的 spec 章节 —— 不是”参考那个规格文件”,而是”参考 §1 数据模型”。AI 的注意力是有限的,指得越准,跑偏越少
  2. 明确产出 —— 具体到文件路径,而不是”把类型定义好”
  3. 写清验收方式 —— 完成后要核对哪些字段、运行哪些检查,直接写进任务;一句“我会验收”不能代替这些标准

我用这个方式推进了 T1(类型定义)和 T2(脚手架 + 设计系统),两个任务加起来 1.5 轮,非常快。快的原因是任务边界清晰 —— T1 只有一个文件、四个类型,AI 不需要做任何架构决策。

2.3 第三步:跑偏时,改 Speckit 而不是改代码#

这是整个方法论里最反直觉、也最有用的一条。

实现拖拽(对应清单里的 T15)前,feature-task-board.md 补充了一节技术选型约束:

## 技术选型约束
- 拖拽库:必须使用 @dnd-kit/core + @dnd-kit/sortable(不用 react-beautiful-dnd)
- 原因:更好的 TypeScript 支持和更小的包体积

然后指令是:

Speckit 里的技术选型已经更新,请重新看一下:
@Files specs/feature-task-board.md
然后重新实现任务 3.1(拖拽排序),使用 @dnd-kit 而不是 react-beautiful-dnd。

注意这里的操作顺序:先改 spec,再让 AI 重读 spec,最后重新实现。

为什么不能直接在代码里改?因为代码是产物,spec 是源头。你手动把 200 行 react-beautiful-dnd 改成 @dnd-kit,代码是对了,但 AI 的”认知”还停留在旧方案 —— 下次让它加个”批量拖拽”功能,它照样按 react-beautiful-dnd 的 API 写。你在代码层修的是一个 bug,在 spec 层修的是一类 bug。

所以在 spec 里加约束时,“原因”那一栏不是形式主义。写上”更好的 TypeScript 支持和更小的包体积”,AI 就理解了这条约束背后的意图,后续遇到 spec 没覆盖的灰色地带(比如要不要引入配套的 utilities 包),它会顺着意图推断,而不是机械执行。


三、模块:代码是怎么被切成一块块的#

3.1 目录结构,以及依赖为什么是单向的#

最终 src/ 长这样:

src/
├── types/task.ts # 数据模型:Task / TaskStatus / Priority / User
├── constants/board.ts # 列配置、优先级权重、优先级展示、业务常量
├── lib/taskOrder.ts # 纯函数:排序 / 分组 / 移动计算
├── api/tasks.ts # 接口封装(含 mock 开关)
├── data/mockTasks.ts # 占位数据
├── components/
│ ├── Board.tsx # DndContext:拖拽编排 + 状态管理
│ ├── BoardColumn.tsx # 列:droppable + SortableContext
│ └── TaskCard.tsx # 卡片:useSortable
├── App.tsx
├── index.css # 设计系统 Token(CSS 变量,单一真源)
├── main.tsx
└── vite-env.d.ts

依赖方向是严格单向的:

types ← constants ← lib ← api ← components

types 不知道任何人,所有人都知道 types。这条约束带来一个直接好处:业务规则可以脱离 UI 独立演进。

3.2 把业务规则放进纯函数里#

看板有 4 条业务规则,其中两条直接影响数据流:

  • 规则 1:移动到”已完成”时,自动更新 updatedAt
  • 规则 2:同列内按优先级排序(critical > high > medium > low),同优先级按创建时间排序

这两条我全部放在 lib/taskOrder.ts 的纯函数里:

export function sortTasks(tasks: Task[]): Task[] // 规则 2
export function applyMove(task: Task, status: TaskStatus): Task // 规则 1
export function groupByStatus(tasks: Task[]): Record<TaskStatus, Task[]> // 分列
export function findTaskStatus(columns, taskId): TaskStatus | null

这么切的理由是:拖拽组件只应该关心”位置”,不应该关心”语义”。Board.tsx 里的逻辑是”把这个 id 从 A 数组移到 B 数组的第 N 位”,至于移进 done 列要不要刷新时间戳、新列要不要重排 —— 那是 applyMove 和 sortTasks 的事。

这个分离在改需求时会立刻体现价值。如果产品经理明天说”已完成列按更新时间倒序排”,我只需要改 sortTasks 一个函数,拖拽那套状态机一行都不用动。

3.3 设计系统也是一个”模块”#

UI 规格里有整整四节是设计系统:颜色(主题色 4 个 + 中性色 10 档 + 语义色 4 个)、字体(6 档字号 + 4 档字重 + 中英文字体栈)、间距(6 档)、圆角(5 档)。

我的处理是两层结构:

第一层:src/index.css 的 CSS 变量(值的单一真源)
--color-primary-600: #2563eb;
--space-24: 24px;
--radius-8: 8px;
第二层:tailwind.config.js 通过 var() 引用(语义化的类名)
colors.primary.600 = 'var(--color-primary-600)'
spacing.xl = 'var(--space-24)'
borderRadius.card = 'var(--radius-8)'

组件里写的因此是 p-lg、rounded-card、text-caption、bg-gray-100 这样的语义类名,而不是 p-[16px]、rounded-[8px]。

这么做有三个好处:

  1. 可追溯:spec 里的每个 hex 值和 px 值,在 index.css 里都能一行对一行地找到,验收时不用满仓库搜
  2. 可替换:改一个变量,全站生效
  3. 可读:rounded-card 比 rounded-[8px] 更能说明”这是卡片的圆角”

一个踩过的坑:用 CSS 变量定义的颜色,不支持 Tailwind 的透明度修饰符(bg-state-error/10 会失效)。因为 var() 对 Tailwind 来说是不透明的字符串,它没法往里插 alpha 通道。解决办法是要么多定义一个变量,要么改用边框/实心背景表达状态。

3.4 未完成的模块,留的是接口不是坑#

清单里 T8(Header)、T12(创建任务)、T13(详情抽屉)都还没做,但代码里已经能看到它们的位置:

TaskCard.tsx
onClick={() => onOpenDetail?.(task)} // T13 详情抽屉接入点
// BoardColumn.tsx
<button type="button">+ 新建任务</button> // T12 创建任务接入点
// App.tsx
{/* T8 Header 接入点 */}
<Board tasks={mockTasks} />

以及 api/tasks.ts 里的开关:

const USE_MOCK = import.meta.env.VITE_USE_MOCK !== 'false';

现在所有”移动任务”的请求走 mock(延迟 200ms 后返回本地计算结果),等 T6 的 POST /api/tasks/:id/move 真实接口就绪,把环境变量设成 false 就切过去了,组件层零改动。

这是模块设计里我最想强调的一条:为没做的任务预留接口位置,但不要提前实现它。提前实现的代码,在真做那个任务时大概率要重写,而且会污染当前任务的验收。


四、交互:这个词在这里有两重含义#

4.1 人与 AI 的交互:什么时候该问,什么时候该自己决定#

这轮实践里有两次判断,我认为是”人机协作”的典型样本。

第一次:指令有歧义,但可以按最合理的理解继续。

任务指令里把路径写成了 src/types/ask.ts,结合 Task 类型的上下文,应该是 task.ts 少了一个 t。AI 按 task.ts 创建,并在回复里说明了这个判断,方便确认或改名。

这种小歧义可以先按更合理的解释处理,但要说明假设。文件改名很容易;如果涉及对外接口或现有引用,就不能只凭文件名判断。

第二次:技术栈缺失,必须停下来问。

T1 做完后,工作区里只有 specs/,没有 package.json、没有 tsconfig.json。T2 要搭脚手架,但规格里没写用什么框架。这时 AI 暂停实现,列出框架选项和推荐理由,请求确认(UI 规格里的 gray-50、blue-600 这类命名与 Tailwind 高度契合)。

框架选型会影响后面 20 多个任务,返工代价远大于改文件名,所以需要先确认。

总结成一条可操作的规则:低成本可回退的分歧 → 按最合理解释继续并声明假设;高成本不可逆的分歧 → 停下来问,并给出带理由的推荐选项。

4.2 产品的交互:拖拽这件事,比看上去复杂得多#

“拖一张卡片到另一列”,用户感知里是一个动作,代码里是三段式:

第一段:onDragOver 处理跨列插入

const handleDragOver = ({ active, over }) => {
const fromStatus = findTaskStatus(columns, activeId);
const toStatus = parseColumnId(overId) ?? findTaskStatus(columns, overId);
if (fromStatus === toStatus) return; // 同列的排序交给 dragEnd
setColumns(prev => ({
...prev,
[fromStatus]: source.filter(t => t.id !== activeId),
[toStatus]: [...target.slice(0, insertIndex), applyMove(moved, toStatus), ...target.slice(insertIndex)],
}));
};

跨列的实时插入必须放在 onDragOver,不能等到 onDragEnd。因为用户在拖的过程中需要立刻看到”这张卡会落在哪一列的哪个位置”,等到松手才知道结果,体验是断裂的。

第二段:onDragEnd 确定最终索引并同步服务端

const reordered = oldIndex === newIndex ? items : arrayMove(items, oldIndex, newIndex);
const movedTask = applyMove(reordered[newIndex], toStatus);
setColumns(nextColumns); // 乐观更新
void persistMove(columns, movedTask, toStatus, newIndex); // 同步

第三段:失败回滚

try {
await moveTask(task, status, position);
} catch {
setColumns(snapshot); // 恢复拖拽前的快照
setError('移动失败,已恢复到原来的位置');
}

乐观更新必须配回滚,否则会出现”幽灵卡片” —— UI 上卡片已经在 done 列,刷新后发现它还在 todo 列。

几个容易被忽略的细节:

  • 5px 激活距离:useSensor(PointerSensor, { activationConstraint: { distance: 5 } })。没有它,鼠标按下再松开(点击)会被判定为拖拽,卡片的点击事件就永远触发不了
  • 列自身要作为 droppable:空列里没有卡片,SortableContext 里没有可碰撞的目标,必须让列容器自己能接收放置,否则拖不进空列
  • 碰撞检测策略:多容器场景用 closestCorners(比 closestCenter 更符合直觉),配合 MeasuringStrategy.Always 持续更新容器测量值
  • 视觉反馈四件套:拖拽中卡片 opacity-80 + 阴影增强、目标列 ring 高亮、悬停位置显示虚线放置框、移动过渡 200ms

4.3 规格里的矛盾,要在实现时说出来#

实现拖拽时我撞上了一个 spec 内部的冲突:

  • 业务规则 2 说:同一列内,任务按优先级排序
  • 功能需求 3.1 说:在列内拖拽调整任务顺序

这两条不能同时严格成立。如果始终按优先级自动排序,用户手动拖的顺序立刻就被覆盖,拖拽等于白拖。

这轮实现采用的规则是:初始分组时按优先级 + 创建时间排序,用户手动拖拽后保留手动顺序(不把卡片自动拽回优先级位置)。AI 在交付说明里单独列出了这个冲突、当前处理方式和另一种选择,留待确认。

这是 AI 协作里非常关键的一点:规格是自相矛盾的时候,不要静默地替用户做决策。AI 的职责是”实现 + 暴露分歧”,决策权应该留在人手里 —— 尤其是这种影响产品行为、且两种选择都合理的分歧。


五、踩坑记录:那些和代码无关的问题#

坑 1:npm install 的输出被吞

第一次 npm install 返回的执行结果是空的(Tool execution failed: No result found),但退出码是 0。AI 没有直接重跑,而是先检查 node_modules/@dnd-kit 是否真的存在 —— 结果依赖是装好的。

经验:命令输出不可信时,用文件系统状态来验证,而不是反复重跑命令。

坑 2:localhost 拒绝连接

服务在容器里跑得好好的(curl 127.0.0.1:5173 返回 200),但本机浏览器访问 localhost:5173 连不上。

原因很简单:容器的 localhost 和本机的 localhost 不是同一个东西。这类云端 IDE 环境里,正确路径是用 IDE 提供的预览能力,而不是直连 localhost。

修法是配置 .vscode/preview.yml:

autoOpen: true
apps:
- port: 5173
run: npm run dev -- --host 0.0.0.0
root: ./
name: task-board
autoOpen: true

顺带一个细节:启动命令必须带 --host 0.0.0.0。Vite 默认只监听 localhost,容器外的任何人都访问不到 —— 这个默认值在本地开发时无害,在容器里是致命的。

坑 3:后台进程被回收

直接在终端 npm run dev,命令”执行完”后进程就没了。原因是进程挂在命令的会话上,会话结束就被回收。解决办法是用 setsid nohup 让进程脱离会话:

Terminal window
(setsid nohup npm run dev -- --host 0.0.0.0 > /tmp/vite-dev.log 2>&1 &)

六、回头看:三个反直觉的收获#

第一,最贵的不是写代码,是写规格。

这轮里,生成 24 条清单花了 20 分钟,T1(类型定义)花了 0.5 轮,拖拽(最复杂的任务)花了 2 轮。但凡是规格没写清楚的地方,返工成本都是这个的几倍 —— 技术选型那次纠偏,就是典型的”规格差一句话,代码多走一段路”。

第二,纠偏的成本取决于你在哪一层动手。

改代码 = 修一个实例,改 spec = 修一类问题。判断标准很简单:如果同样的错误,AI 下次还会犯,那你就改错层了。

第三,角色定位要换。

具体错误仍要在代码里修;如果错误来自遗漏或冲突的约束,还要同步更新规格,避免后续实现重复踩坑。我的主要工作量分布是:读规格、生成清单、下指令、验收、发现矛盾时更新规格 —— 真正”写代码”的部分,几乎全是 AI 做的。


文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

从两份规格文件开始
https://mjzy.tech/posts/ai-engineering/11-从两份规格文件开始/
作者
JIA
发布于
2026-09-04
许可协议
CC BY-NC-SA 4.0

评论区

正在加载 GitHub 评论…

Tips · 随手记
仅自己可见 · 每篇文章单独保存

文章目录