跳到主要内容
查看文档索引

从工程维度提高 Vibe Coding 稳定性:Spec、架构与 Changelog

把一次 AI 辅助改动变成可准入、可实现、可验证、可追踪、可回滚的工程闭环;领域驱动与前端状态解耦只是其中关键的一环。
实践 · 记录 · 分享

我最近做的项目,不再是一个只有几个页面的 Demo。它同时包含市场研究、策略版本、历史回测、参数实验、模拟交易、实盘与通知、全局 Agent、实时行情、异步 Worker 和独立的数据采集后台。

这种项目一旦用 Vibe Coding 加速,最先暴露的通常不是模型不会写代码,而是工程里没有足够清楚的边界,让每一段生成代码知道自己不该做什么。

你让 AI “给页面加一个保存按钮”,它很容易顺手把请求、校验、重试、乐观更新和错误提示都塞进组件;你让它“加一个后台任务”,它可能在内存里记状态,再通过 WebSocket 告诉页面“完成了”;你让另一个会话继续改,新的实现又会复制一套相似但不完全相同的规则。

每一段代码单看都能跑,连起来却越来越难证明。

我后来发现,单靠一次“架构重构”还不够。真正需要的是一套工程变更系统:Spec 决定需求是否准入,历史冲突检查阻止 AI 擅自改写旧决定,领域边界约束实现,分层测试提供证据,Changelog 把需求、代码、验证和回滚重新连起来。

领域驱动和前端状态解耦仍然很重要,但它们只是中间一环。文章真正想分享的是:怎样从工程维度,让一次 Vibe Coding 改动可准入、可实现、可验证、可追踪,也可回滚。

先给结论:稳定性来自一条完整的变更链

我现在把一次改动看成下面这条状态机:

用户需求
  -> Spec 准入
  -> 当前/历史冲突检查
  -> 拥有该行为的 Spec 先产生差异
  -> 领域与状态权威设计
  -> 实现
  -> 分层验证
  -> Changelog 记录实际证据
  -> 版本与索引同步
  -> 可回滚交付

任何一段缺失,AI 都可能“局部完成、整体失真”:

  • 没有 Spec 准入,生成代码会替产品做决定。
  • 没有历史冲突检查,旧会话确认过的兼容边界会被新会话覆盖。
  • 没有领域与状态权威,规则会散到 View、Action、Worker 和 Socket。
  • 没有分层验证,页面文案出现不等于并发、失败和恢复正确。
  • 没有 Changelog 证据,下一位测试者不知道要证明什么。
  • 没有版本、索引和回滚,变更无法被可靠定位、发现或撤销。

所以本文讲的“稳定”,不是 AI 每次都一次写对,而是工程能尽早拒绝错误,并让失败停在最便宜的边界。

第一关不是写代码,而是 Spec 准入

在这个项目里,新功能、Bug 修复、重构、兼容处理、迁移、API、测试和交互变化,都要先找到拥有该行为的永久 Spec。

准入之前会检查五件事:

  1. 用户最终要得到什么可观察结果?
  2. 哪个领域、对象和用例拥有它?
  3. 当前 Spec 已经承诺了什么?
  4. 历史 Changelog 是否保留过相反或更严格的决定?
  5. 哪些行为、不变量、失败语义和验收点必须先写清?

如果没有冲突,先让 Spec 差异出现在工作区,再改运行时代码。Bug 修复要先补上被破坏的不变量和回归点;纯重构要先写明保持不变的外部行为、允许调整的内部边界和验证范围。

如果两份当前 Spec 不一致,或者新方案与历史决定冲突,就不能让 AI 按“更新的文档优先”“更具体的文档优先”或“现有代码最方便”自动裁决。它必须停下来,列出:

  • 拟议行为与用户价值;
  • 冲突文件、条款和历史证据;
  • 当前实现到底符合哪一部分;
  • 不重构时的最小兼容选项与债务;
  • 独立的重构/迁移选项与回滚;
  • 需要人明确确认的决定。

这道门看起来像文档流程,实际是在阻止 AI 获得未经授权的产品决策权。Spec 不是实现完成后的说明书,而是实现开始前的准入凭证。

先别选框架,先画出产品闭环

这个项目现在叫策脉(StratThread)。它真正要支持的不是“展示行情”,而是一条可以追溯的研究闭环:

发现市场
  -> 形成研究判断
  -> 创建或选择自己的策略
  -> 固化为不可变策略版本
  -> 用历史数据回测并检查样本外证据
  -> 用参数实验比较候选方案
  -> 用模拟盘持续观察
  -> 在明确风险后进入实盘或仅通知
  -> 根据持久化证据创建新版本继续迭代

当这条链路确定以后,核心对象会自然出现:用户、市场标的、策略、策略版本、回测、参数实验、模拟盘运行、实盘账户、自动化、推送目标和通知。

领域设计的价值,不只是给它们起名字,而是确定哪些事实不能被随便改:

  • 用户身份必须由服务端认证产生,页面、Socket 数据和 Agent 参数都不能指定 owner。
  • 执行过的策略版本不可变;源码变化必须产生一个新版本。
  • 一次运行要冻结精确的策略版本、参数、市场和必要假设,之后页面切换主版本也不能影响它。
  • 已完成的回测和交易证据不能用“最新行情”重新计算覆盖。
  • 高风险操作需要独立的审批、能力检查和审计边界。
  • 研究、评分和回测都不是收益承诺。

这些约束比 React、Go 或 PostgreSQL 更先出现。技术只是负责承载,不能反过来定义产品。

这也是我认为 Vibe Coding 最该先给 AI 的上下文:不是一张页面截图,而是这次改动属于哪个领域对象、允许什么状态变化、哪些事实绝对不能被改写。

整个工程不是一条“大后端”,而是几种职责

当前运行拓扑可以简化成下面这张图:

Browser / optional native shell
  -> Astro SSR + authenticated Actions
  -> React interactive islands
  -> Application Services
  -> Repository / Engine ports
  -> PostgreSQL / Redis / ClickHouse

Realtime gateway
  -> authenticated subscriptions
  -> replaceable progress, quote and invalidation hints

Worker roles
  -> backtest / Agent / paper trading / live trading
  -> recover exact work from durable records

Independent Go backend
  -> market catalogue and ingestion
  -> confirmed history to ClickHouse
  -> current provisional bars to expiring Redis state
  -> operator control plane

这里有四个主要应用边界:

边界责任不负责什么
WebSSR、认证交互、Actions、React 页面不把浏览器状态当业务事实
Realtime行情、进度、失效和通知提示不拥有最终业务结果
Worker回测、Agent、模拟和实盘的异步执行不靠进程内状态恢复任务
Backend行情目录、采集和运维控制面不复制普通用户的策略用例

它们可以独立替换和扩容。一个实例消失,不应该带走用户的任务、策略、账户或结果。进程内只允许保留连接池、客户端、编译后的 Schema、日志器和一次操作的有界工作集。

这条“无状态”约束很适合 Vibe Coding:AI 很容易为了完成当前函数,临时加一个模块级 Map、单例 Store 或本地文件。明确告诉它“实例随时可被替换”,很多错误实现会在写出来之前就被排除。

Application Service 是多入口之间的中心

同一个业务动作可能从很多地方进入:

  • 用户点击页面,通过 Astro Action 发起;
  • Agent 调用一个 Tool;
  • Socket 连接请求订阅;
  • Worker 恢复一个任务;
  • 运维控制面触发一次受控恢复。

最危险的做法,是在每个入口各写一套授权、校验和状态机。它们开始时很像,几轮 Vibe Coding 之后就会产生语义漂移。

所以项目里把业务用例集中在 Application Service。入口只做适配:构造服务端可信的 ActorContext、校验传输结构、调用一个用例、映射结果。真正的所有权检查、状态转换、持久化编排和领域错误都在同一服务边界内。

Action -----------+
Agent Tool -------+--> ActorContext -> Application Service -> Repository / Engine port
Socket handler ---+
Worker -----------+

这让 Agent 变成了受控适配器,而不是另一套业务系统。模型可以提议创建策略、发起回测或修改配置,但它不能靠 Prompt 文本决定用户身份,也不能绕过与 Web 相同的授权和验证。写操作依旧要经过对应的审批与审计。

还有一个很实用的约束:所有前端业务 API 先登记成统一的 Action/Tool 合同,输入 Schema 只维护一份。页面和 Agent 看到的是同一项产品能力,只是入口和呈现不同。

先给每种状态指定唯一权威

复杂系统里很多“状态 Bug”,本质不是状态太多,而是同一个事实有多个自称权威的副本。

这个项目用一张很直接的表约束它们:

状态权威位置可丢失的投影
用户、策略、版本、运行、账户、意图、审计、通知PostgreSQL浏览器 Store、realtime 提示
已确认历史 K 线ClickHouse图表页面数据
会话、队列、租约、限时进度、当前未完成 K 线Redis,且有命名空间和 TTL进程句柄、Socket 房间
草稿、筛选、弹窗、图表视口浏览器内存刷新后可以安全丢失

这个表会直接改变 AI 写代码的方式。

例如页面收到了 WebSocket 的“回测完成”事件。错误做法是直接把事件里的结果当成最终结果;正确做法是把它当作一个提示,触发一次拥有者范围内的权威读取。漏掉一个包最多让页面晚一点更新,不会让业务事实丢失。

再例如 Redis 暂时不可用。系统不能悄悄换成本地 Map,返回一个看起来成功的空结果。依赖失败要可见,健康检查和就绪检查也要能区分“进程还活着”和“核心数据路径真的可用”。

异步任务的关键不是队列,而是持久化顺序

回测、参数实验、Agent Run、模拟盘和实盘都不是一次 HTTP 请求能安全包住的工作。页面会关闭,Worker 会重启,队列也可能重复投递。

一个可靠的异步链路更接近:

校验输入
  -> 在 PostgreSQL 写入 queued 记录与 delivery outbox
  -> dispatcher 投递紧凑的记录引用
  -> Worker 重新读取冻结输入
  -> 持久化 running
  -> 执行隔离的领域引擎
  -> 持久化 completed / failed 与不可变证据
  -> 最后发送 realtime / Push 提示

这里最重要的一句是:先持久化,再投递。

如果先往队列发消息,再写业务记录,服务在中间崩溃就会得到一条找不到输入的任务;反过来,业务记录和 outbox 同事务提交,dispatcher 即使晚一点运行,也能继续把工作送出去。

队列按“至少一次”设计,所以每个 Run ID 必须幂等。同一消息被投递两次,应该恢复同一个运行,而不是制造第二份结果。Redis 租约只阻止两个 Worker 同时处理,租约不是任务权威;Worker 丢失租约后,新的实例从 PostgreSQL 的检查点恢复。

这套思路也适用于实盘这类高风险动作:持久化意图和稳定的幂等身份,再调用外部交易接口;如果传输结果未知,不能靠自动重试赌一次,需要先通过外部身份核对结果。

Realtime 负责“快”,数据库负责“真”

实时系统常让 Vibe Coding 产生一种错觉:既然页面立刻收到了数据,就把 Socket 当 Store 的后端。

在这套架构里,Realtime 只负责三类事情:当前观察、进度提示和资源失效。它可以让页面立即响应,但不会成为策略、回测、订单或通知的唯一副本。

市场数据也把“当前”和“历史”分开:未完成的当前 K 线可以放在有 TTL 的 Redis 投影里,确认后的 K 线才进入 ClickHouse。交互图表可以把两者组合展示;历史回测只读取已确认数据,绝不能让一根还在变化的 K 线进入可复现证据。

这个界限对 AI 很重要,因为“把最新值 append 到历史数组”太容易写了。领域合同必须告诉它:最新观察和确认事实不是一种数据。

再深入前端:View 不应该知道一次保存如何完成

整个工程里,我最想解决的仍然是前端逻辑的稳定性。

传统写法经常把请求放进 useEffect,把保存放进点击事件,把错误和 loading 分散在组件的多个 useState 里。测试只能渲染整个页面、模拟点击、等待文案出现。只要 UI 改版,业务测试也跟着碎。

当前前端采用一条单向依赖:

View -> Page -> page-scoped Provider -> Domain Store -> Repository -> SDK / Action

每层只做一件事:

  • View 选择需要的状态、渲染,并把用户意图交给 Store。
  • Page 负责组合边界,只调用一次 store.init()
  • Provider 为当前页面创建唯一 Store 实例,注入 Repository,并在卸载时 destroy()
  • Domain Store 管状态机、校验、异步流程、并发、乐观更新、错误和清理。
  • Repository 用领域语言提供能力,负责 SDK/Action 调用和传输映射。

View 中的保存按钮应该接近这样:

const save = useEditorStore((state) => state.save)
const saving = useEditorStore((state) => state.status === 'saving')

return <button onClick={() => void save()} disabled={saving}>保存</button>

它不需要知道保存调用哪个接口、是否有 revision、失败后回滚哪一份快照,也不需要知道上一条请求是否已经过期。

Store 不是数据桶,而是可执行的领域状态机

把逻辑从组件挪到 Store,如果只是把几十个 useState 换成几十个字段,依旧没有解决问题。

Store 需要表达有意义的状态与事件:

idle -> loading -> ready | empty | error
ready -> editing -> validating -> saving -> ready | conflict | error
ready -> destroyed

它还要明确生命周期和并发。当前实现普遍会保留请求版本、生命周期版本和 AbortController。只有发起请求时的版本仍然有效,响应才允许写入 Store。

const requestVersion = ++snapshotRequestVersion
const requestLifecycle = lifecycleVersion

const snapshot = await repository.getSnapshot(id, controller.signal)

if (
  requestVersion !== snapshotRequestVersion ||
  requestLifecycle !== lifecycleVersion
) return

set((state) => {
  state.snapshot = snapshot
})

这样可以证明几个非常具体的行为:

  • React Strict Mode 重复初始化不会重复改变语义。
  • 用户快速切换两个版本,较慢的旧响应不会覆盖新选择。
  • 页面卸载后,迟到的请求不能复活已经销毁的 Store。
  • 一次保存失败时,只回滚受影响的确认状态,不删除用户后来继续输入的草稿。

这些行为如果留在组件的 Effect 和闭包里,测试非常痛苦;进入纯 Store 后,它们就只是输入、事件和状态断言。

前端状态终于可以不渲染页面就测试

Store 通过 Repository 接口接收依赖,测试可以直接注入一个 Fake:

const repository = {
  getSnapshot: jest.fn(),
  saveSnapshot: jest.fn(),
}

const store = createEditorStore(repository)

repository.getSnapshot
  .mockResolvedValueOnce(oldSnapshot)
  .mockResolvedValueOnce(newSnapshot)

const first = store.getState().selectVersion('v1')
const second = store.getState().selectVersion('v2')

await Promise.all([first, second])
expect(store.getState().selectedVersion).toBe('v2')

真实测试还会覆盖:

  • 初始化成功、失败、重试和并发调用;
  • 空数据、边界输入和每一个动作;
  • 乐观更新的立即状态、服务端确认、失败回滚和 revision 冲突;
  • 两次请求乱序完成;
  • 请求开始后用户又输入了新内容;
  • destroy() 时取消请求、计时器、监听和订阅;
  • Repository 的请求结构、数据映射和错误翻译。

View 测试就可以回到它真正负责的部分:语义结构、可发现操作、键盘路径、焦点、无障碍名称和状态反馈。

这不是为了追求更高的测试覆盖率,而是把“业务是否正确”和“页面是否正确呈现”变成两类可以独立失败的问题。

Changelog 不是摘要,而是一份可执行验收合同

Spec 说明“应该是什么”,Changelog 负责说明“这次具体做了什么,又用什么证据证明”。

项目根版本会为每个独立可审查、可验证的里程碑递增一次。每个版本必须有一条同版本 Changelog,并保持这样的映射:

Requirement -> Implementation -> Verification

一条有效记录至少包含:

  • 每个需求及其 owning Spec;
  • 具体修改到的应用、领域、协议、存储或交互边界;
  • 可以复现的测试命令、工具、前置条件、期望与实际结果;
  • 兼容性、迁移影响、已知限制和残余风险;
  • 回滚时要恢复什么、必须保留哪些业务数据;
  • 索引影响:同步了哪些 canonical index,或为什么 No index change

验证结果要诚实使用状态:

状态含义
Passed实际执行并得到预期证据
Failed实际执行但结果偏离合同
Blocked必需门槛缺少权限、成本、凭据、恢复证据或外部条件
Not run没有执行,并记录具体原因和影响

“测试通过”没有命令、范围和结果,不算证据;没有跑的检查也不能通过含糊措辞变成成功。

这套设计对 Vibe Coding 特别重要。新会话先看 Changelog,就知道当前版本有哪些要求、哪些验证已经完成、哪些风险仍然存在。测试者也不需要从 diff 猜验收范围,而是逐条核对需求—实现—证据。

已经发布的版本记录不能为了当前工作“顺手修漂亮”。历史事实保留,新的行为通过新版本和新的 superseded/compatibility 说明前向演进。

版本、索引与回滚让变更真正可定位

版本号不是装饰,它给一个完整工程里程碑一个稳定身份。索引检查则保证新文件、新路由、新 API、新迁移或职责移动不会只存在于作者脑中。

每次交付都要问:这次是否新增、删除、重命名、移动或重新分配了被索引的内容?某个索引描述是否因此不再准确?如果有,所有受影响的 canonical index 必须在同一变更同步。

回滚也不是一句“git revert”。普通代码变化要说明一起回退的 Spec、实现、测试、版本和 Changelog;数据库迁移还需要精确目标、不可变备份或经过验证的前向恢复路径、恢复触发条件和应用兼容性。缺失恢复证据时,迁移执行本身就应该是 Blocked

至此,一次 Vibe Coding 才从“工作区里出现了代码”变成“工程里出现了可识别、可验收、可撤销的变化”。

我现在怎么给 Coding Agent 下任务

比起“帮我加一个功能”,我会先提供下面八项:

1. Spec 准入:哪个永久合同拥有行为,历史是否冲突?
2. 用户结果:完成后用户能做什么?
3. 领域归属:哪个领域和用例拥有它?
4. 不变量:哪些事实不能被破坏?
5. 状态权威:谁保存真相,谁只是投影?
6. 失败与并发:如何取消、重试、回滚和恢复?
7. 验收证据:哪些测试和实际检查证明完成?
8. 交付记录:版本、Changelog、索引和回滚如何同步?

然后要求 AI 先说出它理解的调用链、依赖方向和影响范围,再开始编辑。

例如,不再说:

给策略列表加重命名,失败时提示一下。

而是说:

策略领域新增“重命名自有策略”用例。PostgreSQL 是显示名权威;浏览器允许乐观显示,但必须保留确认快照和请求代次。Repository 负责 Action 映射,Store 负责立即更新、服务端确认、冲突重载、失败回滚和后续输入保留;View 只渲染状态和发出 rename intent。测试覆盖乱序响应、revision 冲突、依赖失败和 destroy 取消。

后一个 Prompt 不一定让 AI 少写代码,却会让它少发明规则。

这套架构不是要求每个按钮都建五层

领域驱动不是目录驱动。一个只改变 hover、展开动画或非业务布局的交互,没有必要进入 Domain Store;它留在组件里反而更清楚。

我通常用一个问题判断:丢失这段状态,会不会改变业务含义、用户决策、异步结果或其他实例的行为?

  • 如果不会,它大概率只是展示状态。
  • 如果会,它需要一个明确的领域所有者和权威位置。
  • 如果它协调跨请求或跨实例,就不能只放在浏览器或进程内。
  • 如果同一个规则被 Web、Agent、Worker 或 Socket 使用,就应该进入共享的 Application Service 或领域端口。

目标不是把代码分得越细越好,而是让变化只能沿着一条可解释、可测试的路径发生。

最后:Vibe Coding 的上限,取决于工程能否拒绝错误

AI 很擅长补全局部实现,却不会天然知道哪些局部选择会破坏整个系统。稳定性来自工程主动拒绝几类错误:让 View 拥有业务流程、让实时消息成为真相、让队列先于持久化、让 Worker 依赖内存恢复、让依赖故障伪装成成功、让不同入口复制同一规则。

领域驱动设计在这里不是一套宏大的术语,而是一组明确问题:

  • 这个行为属于谁?
  • 真相保存在哪里?
  • 哪个边界允许改变它?
  • 失败后从哪里恢复?
  • 不渲染整个系统,能不能证明它正确?

当这些问题有稳定答案,Vibe Coding 才真正从“快速生成代码”变成“快速交付可以继续演进的产品”。

附录:把这套方法直接交给 Coding Agent

我把这篇文章的工程方法整理成了一个可安装的 Codex Skill。它包含完整交付闭环,以及 Spec/Changelog、前端状态、异步运行和验证四个按需读取的参考文件:

stable-vibe-coding/
├── SKILL.md
├── agents/openai.yaml
└── references/
    ├── change-delivery.md
    ├── frontend-state.md
    ├── async-runtime.md
    └── verification.md

下载 stable-vibe-coding Skill(ZIP)

安装后可以这样使用:

Use $stable-vibe-coding to take this change through specification admission,
explicit architecture, verification, and changelog evidence.

Skill 不会替你选择产品方向。它做的是让一次生成代码的请求先通过 Spec 准入,再回答领域归属、状态权威、失败恢复、验收证据与 Changelog 追踪。

完 / 继续实践