普通视图

发现新文章,点击刷新页面。
昨天 — 2026年6月1日技术

AI生成代码快如闪电,但我修了三个小时——它到底帮了谁?

作者 kyriewen
2026年6月1日 18:58

上周,老板丢过来一个内部后台页面的需求,说“不急,明天给就行”。我打开 v0,输入“帮我生成一个用户管理后台,包含表格、筛选、分页、编辑弹窗”。一分钟,页面出来了。表格、按钮、弹窗、样式,一应俱全。我复制代码到项目里,跑起来,看起来没问题。然后接下来三个小时,我都在改它的代码。

一、“神速”生成,然后呢?

v0 生成的速度确实快。但当我开始真正“使用”这份代码时,问题一个接一个冒出来:

  • 变量名随心所欲dataitemsitemDatafilteredData……一个表格数据,四五个名字来回用。改一个地方,全局搜索半天。
  • 样式写死:按钮宽度固定 120px,手机上看溢出;表格列宽固定,屏幕一缩就横向滚动。没有响应式,没有移动端适配。
  • 状态管理混乱:筛选条件、分页、弹窗开关全部挤在同一个 useState 里,改一个导致别的组件无辜重渲染。
  • 没有错误处理:接口返回 null,页面直接白屏。没有 loading,没有空状态。

这些不是 bug,是“能用但没法维护”。

二、修了三个小时,改了啥?

我花时间做的事,恰恰是 AI 最不擅长的事:

  1. 重命名所有变量,让代码能读。
  2. 把固定宽度改成 flex + 百分比,加媒体查询。
  3. 拆分状态,用 useReducer 管理筛选和分页。
  4. 补上 loading、错误提示、空数据占位。

说实话,到最后我有点怀疑:如果我自己从头写,可能也就三个半小时。AI 帮我省了半小时,但我额外付出了“读懂它逻辑”的成本。这笔账,算不过来。

三、AI 到底帮了谁?

我知道,会有人说“你不会写 prompt”“你不会调教”。也许更精确的 prompt 能生成更好的代码。但问题是,AI 生成的代码像一辆组装好的车,看起来能跑,但一上路就发现螺丝没拧紧,轮胎是歪的。它不是不能开,而是你需要先花时间检查所有零件。

对于不熟悉项目上下文、不知道团队规范、不了解业务细节的 AI 来说,生成“能跑”的代码已经是极限。而“可维护”的代码,恰恰需要这些信息。

所以,AI 到底帮了谁?帮我省了半小时打字时间?还是帮老板更快地看到“可视的进度”?对于开发者自己,短期“快”的背后,是长期的“改”。

四、我现在怎么用 AI?

我没有放弃 AI,而是调整了用法:

  • 不用 AI 写核心业务逻辑:自己设计状态和接口,用 AI 生成工具函数或数据 mock。
  • 用 AI 写文档和注释:生成 JSDoc、README、测试用例,这些不会坑人。
  • AI 生成代码后,强制做一轮重构:重命名、拆分、补异常,把“能跑”变成“能维护”。

这样一来,AI 节省的时间,不会在改代码时加倍吐回去。

五、最后

AI 写代码快,但不是免费的。你省下的打字时间,很可能变成修代码的时间。这笔账,建议每个团队都算清楚。

PSD2Code 近期更新与深度解析:从设计稿到生产级代码的完整技术栈

作者 miaowmiaow
2026年6月1日 17:46

引言:为什么我们需要更好的 PSD 转代码工具?

在运营活动、H5页面、电商详情页等场景中,设计稿到前端代码的转换一直是一个痛点。传统工作流下,一个750×6778像素的活动页从PSD到可运行HTML平均需要4-6小时,而psd2code将这个时间压缩到了20秒

最近,我们完成了一系列重要的技术更新和bug修复,并建立了完善的文档系统。特别是针对PSD原生合成语义、光效穿透机制、空组白色污染等核心技术问题的深度解析,让工具更加稳定、智能。本文将带你深入了解psd2code的技术架构、核心算法,以及最新版本的关键改进。

一、完备的文档系统

psd2code拥有完善的文档系统,覆盖从架构概览到具体实现细节的全方位内容:

1.1 架构设计文档

  • 01-architecture/overview.md - 整体分层与模块职责
  • 01-architecture/data-flow.md - 从PSD到HTML的全链路数据流
  • 01-architecture/design-patterns.md - 使用的设计模式分析
  • 01-architecture/directory-layout.md - 目录→模块映射表

1.2 模块详解文档

  • 02-modules/core-*.md - 核心模块(IR/PSD解析/渲染/提取)
  • 02-modules/targets-*.md - 多端输出支持(HTML/React/Vue)
  • 02-modules/semantic.md - 语义化命名系统
  • 02-modules/framework.md - 框架层设计

1.3 关键主题深度解析

  • 03-topics/layout-optimizer.md - 布局优化器算法详解
  • 03-topics/bugfix-2026-06-01.md - 最新bug修复记录
  • 03-topics/empty-pt-group-white-pollution.md - 空PASS_THROUGH组白色污染问题
  • 03-topics/light-blend-penetrate.md - 光效穿透机制
  • 03-topics/group-rendering.md - 组合成策略
  • 03-topics/effects-rendering.md - 效果渲染流水线
  • 03-topics/ir-contract.md - IR作为契约的设计

1.4 扩展开发指南

  • 04-extending/add-a-target.md - 新增一个产物
  • 04-extending/add-a-stage.md - 新增一个处理阶段
  • 04-extending/add-a-layer-handler.md - 新增图层处理器
  • 04-extending/add-an-effect.md - 新增效果渲染器

1.5 开发约定

  • 05-conventions/coding-style.md - 代码风格规范
  • 05-conventions/known-pitfalls.md - 已知坑位与硬约束(必读)
  • 05-conventions/testing-and-validation.md - 测试验证标准流程
  • 05-conventions/ai-handoff.md - AI协作指南

1.6 待办事项

  • 06-todo/ir-typed-upgrade.md - IR类型系统升级计划

文档设计原则

  • 每份文档都有"本文解决什么/不讨论什么"头注
  • 面向"接手维护者 + 协作AI"的双重受众
  • 阅读顺序从上至下,由浅入深
  • 配合实际代码示例,避免理论空谈

二、架构回顾:编译器式分层设计

psd2code采用经典的编译器三段式架构:

PSD → 前端解析 → IR → 后端代码生成 → HTML/React/Vue

核心抽象:

  • IR (Intermediate Representation):基于pydantic的强类型中间表示,作为coretargets之间的严格契约
  • PipelineContext:贯穿所有Stage的全局上下文,承载状态和配置
  • Stage:单一职责的处理步骤,输入输出都是PipelineContext
  • Target:可插拔的产物生成器,通过装饰器注册到全局registry

这种设计让HTML target的能力升级自动惠及React/Vue target,因为它们都是在HTML产物之上的二次加工。

二、近期重要更新(2026年6月1日)

2.1 渐变叠加效果完整支持

问题:PSD中的渐变叠加效果(GradientOverlay)包含多个关键参数(缩放比例、对齐方式、偏移量),之前未完整解析导致效果与Photoshop不一致。

修复

  • 完整解析Scl(缩放比例)、Algn(与图层对齐)、Ofst(偏移)参数
  • 根据"与图层对齐"标志智能选择渐变范围:启用时基于图层尺寸,禁用时基于对角线长度
  • 加入中心点偏移修正,确保渐变位置准确

效果:窄长图层(如18×1864)上的近水平渐变或宽扁图层上的垂直渐变表现完全符合PS

2.2 光效穿透机制的完善

问题:光效穿透(Light Blend Penetrate)机制在处理全光效子组时存在逻辑漏洞,导致黑色污染。

修复

  • 新增_is_fully_suppressed_group()方法,递归判断PASS_THROUGH组内所有可见子层是否都是光效层
  • 优化目标选择逻辑,过滤无效的白色/高亮度目标
  • 新增降级路径:当所有穿透目标都被过滤时,改用CSS mix-blend-mode

效果:解决了web.psd组40/组40拷贝光晕边缘丢失的问题

2.3 调整层与渐变叠加的冲突解决

问题:含有调整层(如曲线、色相饱和度)的剪辑组在处理渐变叠加时,会使用有bug的composite()导致渐变丢失。

修复

  • 新增base_has_overlay检测,识别基础层是否含有渐变叠加
  • 改用ratio-transfer算法:分别获取"仅基础层"和"基础层+调整层"的结果,计算颜色比值
  • 将比值应用到已渲染的渐变效果上

验证:修复前后纵向颜色方差对比

  • 修复前:R=0.2, G=0.3, B=0.7(几乎无渐变)
  • 修复后:R=74.8, G=63.1, B=70.3(渐变完全恢复)

2.4 CSS类名数字开头问题

问题:PSD图层名"18th Anniversary"转换为".18th-anniversary__46",CSS规范不允许类名以数字开头,导致浏览器忽略整条规则。

修复:在_to_kebab()函数末尾增加检查,如果归一化结果以数字开头,自动添加"n"前缀。

2.5 PNG透明边裁剪与CSS同步

问题:PNG透明边裁剪后生成新文件名,但CSS引用未同步更新,导致图片加载404。

修复:将写回条件从pruned_n > 0扩展为pruned_n > 0 or trimmed_n > 0,确保trim操作也能触发CSS更新。

三、核心技术深度解析

3.1 布局优化器:从absolute到Flex的智能重构

LayoutOptimizer是psd2code最核心的功能,它将200+图层的绝对定位代码智能重构为Flex布局,同时保证视觉零偏移

七步流水线

  1. DOM重构:基于聚类算法识别行/列/堆叠结构
  2. 图层扁平化(默认关闭):多image子图层合成单张PNG
  3. 同质兄弟分组:识别平铺的同类卡片,包成v-list
  4. Flex推断:基于趋势检测的智能布局选择
  5. 单子wrapper折叠:消除算法产生的中间层
  6. CSS去冗余:精简z-index,合并等价规则
  7. CSS美化:DOM顺序排序,属性分段展示

聚类算法核心

  • 纵向重叠率≥0.5判定为同行
  • 背景层剥离:完全包含型、主轴覆盖型、双轴主导覆盖型
  • 伪多行装饰堆叠回退机制
  • 二维网格识别:列对齐+跨行对齐的智能处理

实战效果(南瓜大作战H5)

  • CSS行数:4805 → 1499(减少68.8%)
  • CSS块数:457 → 270(减少41.0%)
  • z-index字段:432 → 97(减少77.5%)
  • 6×4任务网格自动识别为v-col+v-row嵌套

3.2 PSD原生合成簇决策:R1-R5硬性规则

compose_cluster.py采用基于PSD硬性合成语义的决策系统,替代历史上的"三道闸门"启发式方法。核心算法通过R1-R5规则识别必须一起合成的图层簇:

规则 触发条件 含义
R1 剪贴蒙版 clipping == 1 剪贴层只能在base的alpha/bbox范围内显示 → 与下方最近的non-clipping base同簇
R2 非NORMAL混合 blend ∉ {NORMAL, DISSOLVE, PASS_THROUGH} OVERLAY / MULTIPLY / SCREEN / LINEAR_DODGE等通过公式修改下层像素 → 必须与下方一起合成
R3 PASS_THROUGH子组+上下文依赖 PT组内含调整层/非NORMAL blend/跨组剪贴 PT组不形成独立合成层,内部依赖会穿透组边界 → 与上下邻居同簇
R4 调整层 adjustment kind 曲线/色阶/曝光等修改下方所有像素 → 与下方一起合成
R5 浏览器不可还原 ≥1个cluster含≥2元素 浏览器alpha堆叠只能还原NORMAL → 被R1-R4粘连成≥2元素的cluster必须合成单张PNG

决策结果

  • merge_full:单cluster全非文本 → 全组合成单图
  • merge_with_text_kept:单cluster+≥1文本+≥1非文本 → 非文本合成为背景,文本独立导出
  • merge_partial:≥2 cluster且存在≥2元素glued cluster → 每个glued cluster单独合成
  • no_merge:全文本或全单元素 → 完全递归导出

设计哲学:将"是否合图"的判定从启发式猜测升级为基于PSD合成语义的硬性规则。

3.3 空PASS_THROUGH组白色污染问题

问题:递归为空的PASS_THROUGH组(组内所有子层展开后无可见像素)在独立cluster合成时,psd-tools的composite()会产生白色污染。

典型案例:"吧台"组中的空组导致PNG亮度从128.8变为164.2(偏差+35.4)。

根因:psd-tools的_apply_passthrough_source中,空组的shape_g = 0divide(x, 0)返回极大值→截断为白色→混合结果整体偏白。

解决方案:在detect_compose_clusters源头过滤递归为空的组:

  • 新增_is_group_recursively_empty()辅助函数
  • 过滤条件:不可见/opacity=0/调整层视为无贡献,子组递归检查
  • 效果:"吧台"组导出亮度从164.2降至123.6,与PSD全图参考值128.8高度吻合

核心原则:如果一个组递归展开后无可见像素内容,它就不应该成为cluster成员。

3.4 光效穿透机制

问题:PASS_THROUGH组中的光照类blend mode光效层(COLOR_DODGE/LINEAR_DODGE/SCREEN/LIGHTEN/LIGHTER_COLOR),其黑色像素在混合中是恒等色,单独导出会产生黑色像素块。

解决方案:五阶段光效穿透流程:

Phase 1:识别光效层

  • 遍历PSD,识别位于PASS_THROUGH组中的光照类混合模式图层
  • 构建LightEffectLayerInfo数据结构

Phase 2:组内向下查找

  • 计算光效层的有效作用区域(考虑Layer Mask/Vector Mask/Clipping)
  • 检查组内下方图层覆盖情况,覆盖率≥90%则不需要穿透

Phase 3:穿透到外组匹配

  • 向上追溯到外组,查找与光效层bbox有交集且有不透明像素的目标图层
  • 构建penetrate_map:目标图层ID → 需要叠加的光效层列表

Phase 4:导出时叠加光效

  • 路径1:叶图层 - 在_export_layer_image中,目标层渲染后叠加光效
  • 路径2:组目标 - 在_merge_group_as_single_image中,composite后叠加光效
  • 光效层只在有底色的区域起作用,输出alpha = 底层alpha

Phase 5:光效层导出抑制

  • 构建suppressed_light_layers集合
  • 独立叶导出路径:跳过被抑制的光效层
  • cluster合成路径:临时隐藏被抑制的光效层,避免黑底参与合成

边界案例:多层穿透、光效层被R2规则粘连、目标层可能是组、光效层被抑制导出等。

3.5 混合渲染策略:组级效果溢出的完美解决方案

挑战:PSD图层效果(描边、阴影、发光)在组级别会沿组边界裁切,psd-toolscomposite()无法处理溢出效果。

解决方案:手动栅格化 + composite混合

  1. 扩展画布手动逐层渲染 → 获取完整溢出像素
  2. group.composite(viewport=bbox) → 获取组内高质量像素
  3. composite结果覆盖到手动渲染的内部区域

效果:外部保留溢出效果,内部达到像素级匹配(Alpha差异max=0, mean=0.00)

3.3 语义化命名系统

三层置信度流水线

  1. Layer2角色推断:识别按钮、背景、卡片等语义角色
  2. Layer1清洗词典:基于common/cn_dict.json的中文关键词映射
  3. Fallback拼音:无匹配时使用拼音作为兜底

智能文件命名

  • 格式:images/<semantic-tag>-<md5前6位>.png
  • 示例:rounded-a3f012.png, btn-receive-279914.png
  • 优势:可读性 + 内容哈希确保git diff稳定

四、默认策略调整:更加保守的优化

4.1 ImageLayerFlatten默认关闭(2026-05-27起)

原因:图层扁平化会删除所有子DOM节点,过于激进,导致语义独立的栅格化元素(按钮、数字框、栅格化文字)被错误合并。

典型案例:抽奖活动"游泳圈"组内:

  • 游泳圈底图(pixel)
  • 数字框矩形(shape)
  • 礼盒文字(栅格化的TypeLayer)

这三个语义独立元素会被合并成单张flat-*.png,丧失独立改色、换文案、绑事件的能力。

新策略:通过--enable-image-layer-flatten显式启用,仅在确认整组是纯装饰时使用。

4.2 Smart Merge的精准控制

--no-smart-merge开关现在只控制LayoutOptimizer链路的两项优化:

  1. DOMRestructure多url背景内联合成
  2. background_flatten文本兜底

这两项优化不删除任何DOM子节点,副作用小,因此默认开启。仅在需要1:1对照PSD图层树或跑像素级回归基线时关闭。

五、多Target架构:一次转换,多端产出

5.1 核心优势

  • HTML target优化自动继承:布局优化、CSS去冗余、语义化命名等能力免费惠及React/Vue
  • 视觉一致性验证:开发者可直接对比html/index_optimized.htmlreact/src/App.jsx
  • 简单可靠的转换逻辑:DOM遍历 + class/style重映射 + 模板语法替换

5.2 产物结构

output/<psd_stem>/
├── html/           # 绝对定位版 + Flex优化版
├── react/          # Vite + React 18项目
└── vue/            # Vite + Vue 3 SFC项目

5.3 开箱即用

cd output/<psd_stem>/react  # 或vue
npm install && npm run dev  # http://localhost:5173

六、实战效果与数据

6.1 性能指标

  • 转换时间:750×6778活动页约20秒
  • 去重率:239个image图层 → 87张PNG(63.6%去重率)
  • CSS压缩:4805行 → 1499行(减少68.8%)
  • DOM节点:约500 → 280(ImageLayerFlatten启用时)

6.2 质量保证

  • 像素级还原:与PSD设计稿完全对齐
  • 浏览器兼容:支持现代浏览器,自动处理中文字宽差异
  • 语义化输出:可读的CSS类名,便于后期维护

七、质量保证与测试验证

7.1 Baseline Diff核心策略

psd2code采用严格的基线对比策略,确保代码变更不引入回归:

核心流程

# 1. 改动前:建立基线
cp -r .codebuddy/skills/psd2code/output /tmp/psd2code-baseline

# 2. 执行代码修改

# 3. 重新运行转换
python3 .codebuddy/skills/psd2code/psd_to_code.py sample.psd

# 4. 整树比对
diff -rq /tmp/psd2code-baseline .codebuddy/skills/psd2code/output
# 期望:零输出(零差异)

双基线验证

  1. 自己改动前的快照 - 验证"代码变更无副作用"
  2. 历史psd2html输出 - 验证"与历史版本保持兼容"

差异化策略

场景 允许差异 做法
故意重命名class/文件名 PR中说明并重建baseline
修复bug(原先就错) 准备before/after截图证明修复正确
新增效果/新字段 ✓(输出变更) 既有PSD不含新效果时应保持零差异
像素有任何变化 ✗ 默认不接受 若无明确理由,属于回归,需修复

多样本验证

  • 普通场景PSD
  • 带大量效果(外描边/投影/发光)的PSD
  • 带嵌套组/剪切蒙版/文字混排的PSD
  • 所有样本都diff零差异才算"通过"

Lint检查

# ruff检查
ruff check .codebuddy/skills/psd2code/scripts

# 提交前确保全项目lint零错误

手动烟测

  1. 打开index.htmlindex_optimized.html目视对比
  2. 浏览器打开查看正常渲染情况
  3. 检查DevTools Console有无JS错误

常见失败模式定位

症状 可能原因 下手点
图片md5变了但名字没变 渲染算法改变 比较images/下两个文件的PIL展示
图层位置偏移 bbox计算变化 检查image_ops._constrain_bbox_to_canvas
组渲染多/少内容 Handler决策改变 检查handlers.py顺序与can_handle
文字变图片 TextExtractor.has_transform判定不同 检查对应方法
Layout优化后HTML diff 优化规则改变 临时禁用LayoutOptimizeStage二分定位
IR校验失败 pydantic字段约束未满足 看报错信息的locmsg

7.2 PSD准备最佳实践

  1. 整理图层结构:按视觉版块分组(版块1-签到 / 版块2-道具 / 版块3-任务)
  2. 语义化命名:关键图层使用中文或kebab-case命名(bg-main / btn-领取 / 用户信息背景
  3. 避免默认命名:减少"图层12拷贝3"、"形状47"等无意义名称

7.3 使用技巧

  • 排查三件套_naming_report.md + layer_map.json + 对比index.htmlindex_optimized.html
  • CSS样式选择--css-style expanded查看PSD坐标溯源注释
  • 调试模式--no-smart-merge保持原始多url CSS,便于1:1诊断

八、未来展望

8.1 近期计划

  • 已完成:HTML/React/Vue三端支持
  • 🚧 进行中:小程序target(架构已预留扩展点)
  • 📅 规划中:Tailwind CSS输出、Figma文件支持

8.2 技术方向

  • 更智能的布局推断:基于机器学习识别设计模式
  • 更丰富的效果支持:PSD高级效果的完整还原
  • 更完善的开发工具:VS Code插件、设计稿对比工具

结语

psd2code不是一个"AI读图猜布局"的玩具,而是一个严格基于PSD结构信息的编译器。每一步决策都可解释、可调参、可单测,算法失败点都有明确的fallback路径。

通过最近的一系列更新,我们修复了多个长期存在的技术问题,优化了默认策略,让工具更加稳定可靠。如果你也在做活动页、长图详情页、运营H5,欢迎试用并提反馈。


参考资料

  • 项目入口:psd_to_code.py
  • 完整文档:doc/README.md
  • 已知坑位:doc/05-conventions/known-pitfalls.md(必读)
  • 测试验证:doc/05-conventions/testing-and-validation.md
  • 布局优化器深度解析:doc/03-topics/layout-optimizer.md
  • 最近Bug修复:doc/03-topics/bugfix-2026-06-01.md
  • 空组白色污染:doc/03-topics/empty-pt-group-white-pollution.md
  • 光效穿透机制:doc/03-topics/light-blend-penetrate.md
  • 组合成策略:doc/03-topics/group-rendering.md
  • 效果渲染流水线:doc/03-topics/effects-rendering.md
  • IR契约设计:doc/03-topics/ir-contract.md
  • CoreExtract模块:doc/02-modules/core-extract.md(含R1-R5合成簇规则)

项目地址:github.com/miaowmiaow/… 如有问题或建议,欢迎在GitHub提交Issue或PR。

昨天以前技术

Claude Code Harness 工程:数仓侧落地方案|得物技术

作者 得物技术
2026年5月21日 10:21

一、AI Coding 现状与痛点:为什么需要 Harness

当前使用情况

得物离线数仓各小组已基本完成 AI Coding 工具的覆盖,主力工具为 Claude Code,辅以数据平台的 IDE 插件,应对重复性工作时效率提升明显。

核心痛点

尽管整体提效已显现,但团队在实际使用中暴露出三类结构性痛点。

痛点一:AI 不记得上下文约束,开发过程中反复"失忆"。会话开始时告知了"金额字段单位是千元",对话进行到一半后 AI 忘了,生成的 SQL 把千元当元用,导致数据差了 1000 倍。这不是偶发问题,而是 Claude Code 的 context compact 机制(上下文压缩)的系统性限制:当对话 token 接近上限(约 95%)时,历史内容被自动压缩为摘要,临时口头说的约束全部丢失。

痛点二:规范执行不稳定,靠记忆兜底的部分最容易出问题。OneData 命名规范、注释三段式、INSERT 必须带 PARTITION 子句......这些规范大家都知道。在项目工期紧张时,人工规范遵守率降至 60%~70%,AI 靠 prompt 记忆的规范遵守率也只有 70%~80%。真正需要的是:把规范从"LLM 记忆中的指导性内容"变成"每次执行时强制检查的护栏"。

痛点三:大型需求开发中,context 很快被撑满,越到后期 AI 越不可靠。复杂需求(大型宽表、大量下游血缘)的典型开发过程:

血缘查询结果(500~3000 tokens) 自测 23 条 SQL 执行结果(5000~15000 tokens) SKILL 规范文件内容(~10000 tokens) 数据比对两表样本(大量行)= context 迅速膨胀 → compact 触发 → 关键约束遗忘 → AI 开始犯低级错误

核心矛盾:越是复杂的需求,越依赖 AI;但越复杂的需求,context 越容易撑满,AI 越容易"失忆"。

从"随手问 AI"到"把能力封装起来"

针对上述痛点,我们沿用了一条反复验证的结论:规范执行是人的短板、AI 的长板;业务判断是 AI 的短板、人的长板。Harness 工程的目标,就是把"执行层"的不稳定因素系统性地消掉:把规范写进 hooks,不再靠 AI 记忆,每次写 SQL 文件后自动触发检查;把迭代约束写进持久化文件,compact 后自动重新注入,不再靠临时口头说;把高 token 操作隔离到 subagent,主 context 只接收摘要,不被过程数据撑满。

二、先搞清楚"Harness"是什么

在 Claude Code 的 update-config skill 描述中有一句话:"Automated behaviors require hooks configured in settings.json --- the harness executes these, not Claude"。

Harness = Claude Code 的宿主运行框架,即 Claude Code 客户端本身这个"工具链容器"。它:管理 context window 生命周期;在 LLM 推理循环之外确定性地执行 hooks;协调 subagents 的生命周期;不依赖模型判断,直接执行配置的自动化行为。区别总结:

三、核心问题:compact 到底丢掉什么?

每次数仓开发 context 接近满时,auto-compact 触发(默认 95% 时触发),会把整个对话历史替换为一份摘要,token 缩减到原来的约 12%。哪些内容 compact 后丢失:

数仓场景最痛的点:对话中说的"这次用 OVERWRITE 模式"、"先忽略 field_a 字段" → compact 后全忘;SKILL 文件读了一半,compact 后前几个 step 的内容没了 → Claude 重复询问;自测跑出来 50 行结果,加上血缘查询的几十行表结构 → context 很快膨胀到 compact 阈值。

四、五层防御体系(从简单到复杂)

第一层:写死进 CLAUDE.md(立即可用)

机制:项目根目录 .claude/CLAUDE.md 每次 compact 后从磁盘重新注入,是最可靠的持久化位置。将当前迭代的关键信息写入,格式建议:

# 当前迭代状态(每次迭代手动更新)## 正在开发- 表:db_a.dws_table_a
 版本:V1.0
 node_id:1000000001
 状态:ETL开发阶段(Step 3/8)

## 本次迭代约束- 禁止修改:dwd_table_b(已上线,只读)
 分区字段:partition_dt(格式 yyyyMMdd,不是 dt)
 amount 字段单位:千元(不是元)

## 数仓全局规范
 建表:分区字段必须是 partition_dt string
 禁止:SELECT *UPDATE/DELETEWHERE
 金额字段用 DECIMAL(20,4),不用 DOUBLE
 INSERT 必须带 PARTITION 子句

操作规则:进入新迭代时,更新"正在开发"和"本次迭代约束"两节;上线后清空"本次迭代约束";全局规范长期保留,控制在 100 行以内。

第二层:Auto Memory 自动积累(已在运行)

机制:Claude 自动将跨会话发现写入 ~/.claude/projects//memory/MEMORY.md,每次 compact 后重新注入。数仓场景下主动触发 Claude 写记忆的时机:"这张表的 amount 字段单位是千元,请记住";"field_a 在特定场景下会为空,请记住这个踩坑";"本次 V1.0 的关键变更是 field_b 逻辑调整,请记住"。Claude 会自动写入 MEMORY.md,下次会话或 compact 后自动恢复。

第三层:hooks 自动验证(核心防御,解决"忘了检查"的问题)

这是解决"每次写完 SQL 自动检查"的关键机制。

配置文件位置

数仓项目根目录/
└── .claude/
    ├── settings.json          ← hooks 在这里配置
    ├── CLAUDE.md              ← 数仓规范上下文
    └── hooks/
        ├── validate_sql.sh          ← SQL 规范自动检查
        ├── block_dangerous_ddl.sh   ← 危险 DDL 拦截
        └── inject_context.sh        ← compact 后重注入上下文

settings.json 完整配置

{"hooks": {"PostToolUse": [{"matcher": "Write|Edit","hooks": [{"type": "command","command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/validate_sql.sh","timeout": 60,"statusMessage": "检查 SQL 规范..."}]}],"PreToolUse": [{"matcher": "Bash","hooks": [{"type": "command","command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block_dangerous_ddl.sh"}]}],"SessionStart": [{"matcher": "compact","hooks": [{"type": "command","command": "cat \"$CLAUDE_PROJECT_DIR\"/.claude/context/dw_conventions.md","statusMessage": "重注入数仓规范..."}]}],"Stop": [{"hooks": [{"type": "prompt","prompt": "检查用户要求的所有任务是否都已完成。如果还有未完成项,返回提示但不要重新开始。检查 stop_hook_active 是否为 true,如是则直接 exit。","model": "claude-haiku-4-5-20251001"}]}]}}

SQL 规范自动检查脚本

.claude/hooks/validate_sql.sh:

#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo"$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('tool_input',{}).get('file_path',''))" 2>/dev/null)

# 只处理 .sql 文件
[[ "$FILE_PATH" != *.sql ]] && exit 0
[[ -z "$FILE_PATH" ]] && exit 0

SQL=$(cat "$FILE_PATH" 2>/dev/null)
[[ -z "$SQL" ]] && exit 0

ERRORS=()

# 规范1:禁止 SELECT *echo "$SQL" | grep -iqE 'SELECT\s+\*' && ERRORS+=("CRITICAL: 发现 SELECT *,必须明确列名")

# 规范2:INSERT 必须带 PARTITIONif echo "$SQL" | grep -iqE 'INSERT\s+(INTO|OVERWRITE)'; thenecho "$SQL" | grep -iqE 'PARTITION\s*\(' || ERRORS+=("CRITICAL: INSERT 缺少 PARTITION 子句")
fi# 规范3:DOUBLE 类型金额echo "$SQL" | grep -iqE '\bDOUBLE\b' && ERRORS+=("WARNING: 金额字段建议用 DECIMAL(20,4),不用 DOUBLE")

# 规范4:UPDATE/DELETE 必须有 WHEREif echo "$SQL" | grep -iqE '\b(UPDATE|DELETE)\b'; thenecho "$SQL" | grep -iqE '\bWHERE\b' || ERRORS+=("CRITICAL: UPDATE/DELETE 缺少 WHERE 条件")
fiif [ ${#ERRORS[@]} -gt 0 ]; thenecho "=== SQL 规范检查失败:$FILE_PATH ===" >&2
  for err in"${ERRORS[@]}"; doecho "  $err" >&2
  doneexit 2
fiecho "SQL 规范检查通过: $(basename $FILE_PATH)" >&2
exit 0

危险 DDL 拦截脚本

.claude/hooks/block_dangerous_ddl.sh:

#!/bin/bash
INPUT=$(cat)
CMD=$(echo"$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('tool_input',{}).get('command',''))" 2>/dev/null)

# 拦截生产表 DROP/TRUNCATE(放行 _dev/_test/_stg 后缀)if echo "$CMD" | grep -iqE '\b(DROP\s+TABLE|TRUNCATE\s+TABLE)\b'; thenif ! echo "$CMD" | grep -qiE '(_dev|_test|_stg)\b'; thenecho "BLOCKED: 检测到生产表 DROP/TRUNCATE 操作,请确认表名是否正确" >&2
    exit 2
  fifiexit 0

hook 通信协议关键规则

关键陷阱:阻断必须用 exit 2,用 exit 1 不会阻止 Claude 继续执行。

第四层:subagents 做上下文隔离(防 context 膨胀)

核心原则:把"高 token 消耗但结果只需要摘要"的操作放到 subagent 的独立 context 中执行。

数仓场景适合下放 subagent 的操作

创建 subagent 文件

.claude/agents/sql-validator.md(SQL 语法验证):

---
name: sql-validator
description: ODPS/MaxCompute SQL 语法验证与规范检查专用 agent。当用户生成或修改 SQL 文件后需要验证时调用。在独立 context 运行,防止大量验证日志污染主对话。
tools: Read, Bash, Grep, Glob
model: haiku
permissionMode: dontAsk
---

你是数仓 SQL 规范专家,只做验证,不修改文件。

验证项(按优先级):
1. SELECT * 禁止
2. INSERT 必须带 PARTITION
3. 字段用 snake_case 命名
4. 金额字段用 DECIMAL 不用 DOUBLE
5. 多表 JOIN 必须有 ON 条件
6. 笛卡尔积风险检测

输出格式:
- 状态:PASS / FAIL
- 问题列表(CRITICAL / WARNING / INFO)
- 修改建议(具体到行号)

不超过 50 行,只返回结构化报告。

.claude/agents/dw-explorer.md(防止血缘查询撑爆主 context):

---
name: dw-explorer
description: 数仓结构探索 agent。当需要大量读取表结构、DDL、字段信息、血缘关系时自动调用,避免大量文件内容污染主 context。只读,不修改任何文件。
tools: Read, Glob, Grep, Bash
model: haiku
permissionMode: dontAsk
---

你是数仓探索专家,只读操作。

当被调用时:
1. 读取指定表的 DDL、字段信息、分区策略
2. 分析上下游血缘(一层)
3. 识别关键字段口径(金额、日期、状态类字段)

输出:不超过 80 行的结构化摘要,包含:
- 表基本信息(层级、粒度、分区策略)
- 核心字段定义(含口径说明)
- 上下游血缘(只列表名,不展开内容)
- 发现的特殊口径或踩坑点

使用方式

在主对话中自然触发:

用 dw-explorer 分析 db_a.dwd_table_a 的结构

对刚生成的 insert_dws_table_a.sql 用 sql-validator 验证

或强制指定(避免 Claude 自行判断):

@"sql-validator (agent)" 验证 path/to/insert.sql

第五层:SKILL 文件改造(减少 context 消耗)

当前的问题:每次调用 SKILL 文件(01~08.md),内容全部加载进主 context,加速 compact 触发。改造方向:把 SKILL 文件的"执行步骤"提炼成 subagent 指令,subagent 内部读完整 SKILL 文件,主 context 只接收结果摘要;用 path-scoped rules 替代 SKILL 文件中的规范章节,按需加载:

---
# .claude/rules/etl-rules.md
paths:
  - "**/*insert*.sql"
  - "**/*_di.sql"
  - "**/*_df.sql"
---

# ETL 开发规范(按文件路径自动加载)
- 必须有 partition_dt 分区
- INSERT OVERWRITE 前检查分区是否已存在
- 不允许跨库 JOIN

SKILL 文件保持现状,但改变调用方式:不要在主对话中直接触发,而是启动一个 subagent 来读 SKILL 文件执行 ETL,主对话只接收最终产出的 SQL 文件路径。

五、可行落地方案:数仓 Harness 架构

先让我们看一下整体的架构设计(整体架构图):

这张架构图的核心逻辑是职责分层:不同类型的工作交给最合适的机制去做,而不是全部压在 Claude 的推理循环里。

持久化层解决的是"失忆"问题,任何临时口头说的约束,compact 后都会消失;但写进 .claude/CLAUDE.md 的内容,每次会话启动和 compact 后都会从磁盘重新注入------这是整套方案里最简单也最可靠的一层。

Harness 层(Hooks) 解决的是"规范靠记忆"的问题,PostToolUse hook 在每次写 .sql 文件后确定性触发,不依赖 Claude 有没有记住规范要求;违规时 exit 2 强制阻断,Claude 必须修正后才能继续,规范遵守率从 70%80% 提升到 95%+。

Subagent 层解决的是"context 被撑满"的问题,血缘查询、23 项自测、数据比对这类操作会产生大量 token,放到独立 context 的 subagent 里执行,主会话只接收一份摘要,compact 触发频率预计降低 50%70%。

三层机制分工明确,互不干扰:Hooks 处理"写动作触发的规范检查",subagent 处理"读操作的 context 隔离",CLAUDE.md 处理"跨会话状态持久化"。为了配合这套架构落地,数仓的研发流程可以拆解为 8 个大的步骤,流程图如下:

从 Harness 架构的视角来看,8 个步骤可以按"对 context 的影响"分成两类:一类是直接在主会话处理 (内容量有限,context 压力低):需求分析(读 PRD)、技术设计(写规范说明)、SR 导入(生成配置)、SLA/DQC(生成规则)。另一类必须通过 Harness 机制处理(否则会加速 compact 或规范失控):ETL 开发每次写 .sql 文件,PostToolUse hook 自动触发规范检查,不依赖人工提醒;自测时 23 条 SQL 的执行结果体量大,交给 data-quality-checker subagent 隔离,主会话只收 PASS/FAIL 摘要;数据比对时两表样本数据量大,交给 data-comparator subagent 隔离;性能优化时血缘 + 多层 DDL 每次 500~3000 tokens,交给 dw-explorer subagent 隔离。这种分工不是把步骤拆开独立执行,而是在同一个工作流里,让每个步骤以最合适的方式运行------context 压力小的步骤留在主会话保持流畅,context 压力大的步骤通过 subagent 隔离保持干净,规范检查通过 hook 自动执行不需要人工干预。

六、基于 SKILL 规范的数仓工作流设计

数仓 8 步 SKILL 规范(需求分析→技术设计→ETL→自测→数据比对→SR 导入→性能优化→SLA/DQC)天然对应 Harness 的三层机制。核心思路是:SKILL 文件不变,改变调用方式------主对话只读规范结论,实际执行由 subagent 或 hook 完成。

各步骤推荐提示词与工作流

Step 1:需求分析

推荐提示词:

用 dw-explorer subagent 先读取上游表结构(只返回摘要),
然后按需求分析规范生成:
1. 需求摘要(≤5行)
2. 表字段口径草稿
3. 待确认问题清单(按优先级排序)
需求文档 URL:[粘贴PRD链接]

Hook 配合:SessionStart 注入当前迭代约束(版本号/表名/禁止修改的表)。

Step 2:技术设计

推荐提示词:

基于上一步确认的需求,按 OneData 规范完成技术设计:
 表名:[按 层级_域_主题_粒度_周期 格式命名]
 粒度:[描述]
 分区:partition_dt string(格式 yyyyMMdd)
 禁止:任何与上游不一致的字段命名
输出 OneData 建模说明,不超过 60

CLAUDE.md 写入(设计完成后手动更新):

## 当前迭代技术设计决策- 表名:db_a.dws_table_a
- 主键:order_no + partition_dt
- 特殊约束:amount 字段继承上游千元单位,不做转换

Step 3:ETL 开发

这是 Harness 工程价值最高的步骤,PostToolUse hook 在每次 SQL 文件保存时自动触发。

推荐提示词:

按 ETL 开发规范生成建表 DDL + Insert SQL- 建表文件:ddl_[表名].sql
- 插入文件:insert_[表名].sql
- 要求:INSERT 用 OVERWRITE 模式,PARTITION 子句必须包含 partition_dt
- 金额字段:DECIMAL(20,4),单位继承上游(千元)
生成完毕后,用 sql-validator subagent 验证两个文件

Hook 自动执行(无需手动触发):每次写入 .sql 文件 → PostToolUse hook 自动运行规范检查。若发现 SELECT * 或缺少 PARTITION → 返回 exit 2,Claude 收到错误自动修正。

Step 4:自测

推荐提示词(防止自测结果撑爆主 context):

用 data-quality-checker subagent 对 [表名] 执行 23 项标准自测,
bizdate = [日期]
补充口径约束:[如"is_perform=1 只取履约订单"]
只返回:PASS/FAIL 汇总 + FAIL 项详情(≤50行),不返回原始 SQL 执行结果

效果:23 条 SQL 的执行结果全在 subagent context 里,主对话只收到一份摘要报告。

Step 5:数据比对

推荐提示词:

用 data-comparator subagent 对比:
 新表:[新表名] partition_dt = [日期]
 参考表:[旧表名/线上表]
 比对字段:[核心金额字段列表]
 容差:≤ 0.01%(金额类)
只返回:差异超过容差的字段列表 + 差值,不返回全量对比数据

Step 6:SR数据库导入

推荐提示词(自动生成最优数据库建表参数):

用 dw-sr SKILL生成建表任务, 先查以下表的 DDL 和一层上下游血缘(只返回摘要):
- 源表:[ODPS表名]
- 目标表:[SR表名]

然后基于 DDL 摘要,分析当前 SR 同步任务的配置风险:
1. 字段类型是否有精度丢失风险(DECIMAL/DOUBLE → DECIMAL(38,18))
2. Key 字段选择是否合理(重复率是否过高导致 DUPLICATE KEY 膨胀)
3. 分区数量是否合理(partition_live_number 与下游查询窗口是否匹配)
4. DISTRIBUTED BY HASH 的 bucket 数与数据量是否匹配
5. 是否有 DATETIME 字段在 SR 侧用了 VARCHAR 存储(会导致时间过滤走全表扫描)

输出同步任务配置建议(按风险高低排序),不超过 20 行。
每条格式:[风险等级 高/中/低] 问题描述 → 建议修改方式

Step 7:性能优化

推荐提示词(防止血缘查询撑爆主 context):

用 dw-explorer subagent 先查 [表名] 的一层上下游血缘和 DDL(只返回摘要),
然后分析当前 Insert SQL 的性能瓶颈:
1. 是否有全表扫描
2. 是否有笛卡尔积风险
3. 是否可以用 MAP JOIN 替代 HASH JOIN
输出优化建议(按收益排序),不超过 30

Step 8:SLA/DQC

推荐提示词:

按 SLA/DQC 规范为 [表名] 生成 9 类 DQC 规则:
 完整性:主键非空、分区数据量
 准确性:核心金额字段与上游比对(容差 0.01%)
 一致性:is_perform 与 perform_flag 联动逻辑
 时效性:产出时间 SLA ≤ 次日 8:00
输出 DQC 配置 JSON,可直接使用

SKILL 调用方式改造(减少主 context 消耗)

当前问题:触发 SKILL 时,SKILL 文件全文(约 10KB)加载进主 context,加速 compact。改造方向:

核心原则:主对话只接收决策级信息(PASS/FAIL、差异字段、优化方案),不接收过程数据(SQL 执行结果、原始血缘列表、完整 DDL 内容)。

精准对话流设计:控制AI思考的艺术

文章中多次提到 /dw-etl、/dw-自测 等命令,这是数仓研发全流程 SKILL 套件的核心触发词,每个命令对应一个封装了规范、产出格式和工具调用的标准化执行单元。核心理念:把每次都要重复讲的要求写进 SKILL,把每次都怕忘的检查点写进 SKILL,把每次都需要的结构化输出也写进 SKILL------这样谁来做需求都行,底座是一致的。

8 个 SKILL 命令一览

以 /dw-etl 为例:一条命令封装了什么?ETL 开发(Step 3)是 Harness 工程价值最高的步骤,SKILL 文件内封装了:① 规范内容(写死,不靠记忆):建表规范:分区字段必须是 partition_dt STRING(格式 YYYYMMDD);金额字段:DECIMAL(26,4),不用 DOUBLE;INSERT 模式:必须使用 INSERT OVERWRITE,必须带 PARTITION 子句;禁止:SELECT *、跨库 JOIN(非血缘关系)。② 产出格式(结构化,不走样):

输出文件:
├── ddl_[表名].sql      ← ODPS 建表语句(含注释三段式、生命周期配置)
├── insert_[表名].sql   ← ODPS Insert SQL(含分区裁剪、JOIN 规范)
└── ddl_sr_[表名].sql   ← SR 建表语句(Key 列顺序、DECIMAL 精度)

③ 自动护栏(Hook 配合):每次 .sql 文件写入后,PostToolUse hook 自动执行 validate_sql.sh;发现 SELECT * 或缺少 PARTITION → exit 2 阻断,Claude 强制修正。④ subagent 卸载(防 context 膨胀):"生成完毕后,用 sql-validator subagent 验证两个文件";验证结果全在 subagent context 里,主对话只收到"PASS/FAIL + 问题列表"。综合效益数据:

七、落地步骤

步骤一:项目级上下文持久化。在数仓项目目录下创建 .claude/CLAUDE.md,写入当前迭代状态。每次新迭代开始时更新"正在开发"和"本次迭代约束"两节,上线后清空约束节。全局规范永久保留,控制在 100 行以内。

步骤二:配置 hooks 自动验证。创建 .claude/settings.json + hooks/ 目录,配置 PostToolUse hook(每次写 .sql 后自动规范检查)和 PreToolUse hook(拦截危险 DDL)。效果:不需要每次手动说"帮我检查 SQL 规范",hook 在 Harness 层自动触发,不占用 Claude 推理资源。

步骤三:创建 subagents 隔离高 token 操作。创建三个核心 subagent 文件:sql-validator.md、dw-explorer.md、data-quality-checker.md。将 Step 4/5/7 的执行全部下放,预计主 context compact 频率降低 50%~70%。

八、Harness 工程能解决的核心问题

这一节是整个方案的出发点,也是对"为什么要这么做"的直接回答。

数仓 AI 开发当前的本质瓶颈:语义理解

在实际数仓 AI 开发中,技术能力不是瓶颈,语义理解才是。需求理解偏差占总返工的 40%~60%,大约 40%~50% 的工作时间花在理解需求和与业务核对口径上。精准血缘探查:准确率提升显著,远超传统方式。这两个观察揭示了同一个问题:AI 在数仓场景的不准确,根本原因不是"不会写 SQL",而是"不理解业务语义"------不知道这张表的 amount 单位是千元还是元,不知道 is_perform=1 在这个版本里意味着什么,不知道某字段在特定场景下会为空。

语义 × 数据 = 准确率

数仓 AI 开发的准确率,可以用一个公式来理解:

准确率 = 语义理解深度 × 数据规范覆盖度

结论:Harness 工程的本质,是把"语义"和"规范"从不可靠的 LLM 记忆中,迁移到确定性的 hooks + 持久化文件里,从而让 语义 × 规范 = 准确率 这个等式两边的变量都变得稳定。

具体能解决的四类问题

问题一:字段口径遗忘导致的计算错误

现状:对话开始时告知了"amount 字段单位是千元",compact 后 Claude 忘了,生成的 SQL 把千元当元用,导致数据差 1000 倍。Harness 解法:字段口径写进 .claude/CLAUDE.md(compact 后重新注入);踩坑经验写进 Auto Memory(跨会话持久化);结果:这类错误从"时常发生"降到"基本不出现"。

问题二:需求理解偏差导致的返工

现状:需求文档描述的是"用户视角的 GMV",但 AI 生成了"交易视角的 GMV",两者口径不同,数据对不上,需要返工。Harness 解法:需求分析的产出(口径草稿 + 待确认问题清单)写进 CLAUDE.md 的迭代约束节;Stop hook 检查任务完整性,确认待确认问题清单已全部明确后才放行;结果:需求一次交付通过率从约 50% 提升到 90%。

问题三:SQL 规范执行不一致

现状:规范文档写了"INSERT 必须带 PARTITION",但 Claude 有时会忘,或在 compact 后规范内容被清除后生成不合规的 SQL。Harness 解法:PostToolUse hook 在每次写 .sql 文件后自动执行规范检查,不依赖 Claude 记忆;违规时 exit 2 返回错误,Claude 强制修正后才能继续;结果:规范执行率从"依赖 LLM 记忆的 70%~80%"提升到"hook 强制的 95%+"。

问题四:大型需求开发中的 context 耗尽

现状:复杂需求(大型宽表、大量下游血缘)开发过程中,血缘查询 + 自测结果 + SKILL 文件内容堆满 context,compact 触发后丢失关键约束,Claude 开始犯低级错误。Harness 解法:血缘查询 → dw-explorer subagent(独立 context,只返回摘要);23 项自测 → data-quality-checker subagent(独立 context,只返回 PASS/FAIL 报告);SKILL 文件内容 → subagent 内部读取,主 context 只接收产出的 SQL 文件路径;结果:主 context compact 频率预计降低 50%~70%。

与传统数仓 AI 开发方式的对比

从"AI 帮我写代码"到"AI 嵌入研发流水线"

Harness 工程的最终目标,不是让 Claude 更聪明,而是让整个研发流水线更可靠。Claude(LLM)负责:理解需求、设计方案、生成代码------这些需要语义理解的事;Harness(hooks)负责:规范检查、危险拦截、任务完整性验证------这些需要确定性执行的事;Subagents 负责:大量文件读取、血缘查询、自测执行------这些会消耗大量 context 的事;CLAUDE.md + Memory 负责:字段口径、迭代约束、踩坑经验------这些需要跨会话持久化的事。这四层分工,让数仓 AI 开发从"对话驱动的一次性辅助"升级为"规则嵌入的流水线自动化"。

往期回顾

1.[BP Claw 破解 AI 编码输入难题 ------FlinkSpec 需求智能化实践|得物技术](# 一、AI Coding 现状与痛点:为什么需要 Harness

当前使用情况

得物离线数仓各小组已基本完成 AI Coding 工具的覆盖,主力工具为 Claude Code,辅以数据平台的 IDE 插件,应对重复性工作时效率提升明显。

核心痛点

尽管整体提效已显现,但团队在实际使用中暴露出三类结构性痛点。

痛点一:AI 不记得上下文约束,开发过程中反复"失忆"。会话开始时告知了"金额字段单位是千元",对话进行到一半后 AI 忘了,生成的 SQL 把千元当元用,导致数据差了 1000 倍。这不是偶发问题,而是 Claude Code 的 context compact 机制(上下文压缩)的系统性限制:当对话 token 接近上限(约 95%)时,历史内容被自动压缩为摘要,临时口头说的约束全部丢失。

痛点二:规范执行不稳定,靠记忆兜底的部分最容易出问题。OneData 命名规范、注释三段式、INSERT 必须带 PARTITION 子句......这些规范大家都知道。在项目工期紧张时,人工规范遵守率降至 60%~70%,AI 靠 prompt 记忆的规范遵守率也只有 70%~80%。真正需要的是:把规范从"LLM 记忆中的指导性内容"变成"每次执行时强制检查的护栏"。

痛点三:大型需求开发中,context 很快被撑满,越到后期 AI 越不可靠。复杂需求(大型宽表、大量下游血缘)的典型开发过程:

血缘查询结果(500~3000 tokens) 自测 23 条 SQL 执行结果(5000~15000 tokens) SKILL 规范文件内容(~10000 tokens) 数据比对两表样本(大量行)= context 迅速膨胀 → compact 触发 → 关键约束遗忘 → AI 开始犯低级错误

核心矛盾:越是复杂的需求,越依赖 AI;但越复杂的需求,context 越容易撑满,AI 越容易"失忆"。

从"随手问 AI"到"把能力封装起来"

针对上述痛点,我们沿用了一条反复验证的结论:规范执行是人的短板、AI 的长板;业务判断是 AI 的短板、人的长板。Harness 工程的目标,就是把"执行层"的不稳定因素系统性地消掉:把规范写进 hooks,不再靠 AI 记忆,每次写 SQL 文件后自动触发检查;把迭代约束写进持久化文件,compact 后自动重新注入,不再靠临时口头说;把高 token 操作隔离到 subagent,主 context 只接收摘要,不被过程数据撑满。

二、先搞清楚"Harness"是什么

在 Claude Code 的 update-config skill 描述中有一句话:"Automated behaviors require hooks configured in settings.json --- the harness executes these, not Claude"。

Harness = Claude Code 的宿主运行框架,即 Claude Code 客户端本身这个"工具链容器"。它:管理 context window 生命周期;在 LLM 推理循环之外确定性地执行 hooks;协调 subagents 的生命周期;不依赖模型判断,直接执行配置的自动化行为。区别总结:

三、核心问题:compact 到底丢掉什么?

每次数仓开发 context 接近满时,auto-compact 触发(默认 95% 时触发),会把整个对话历史替换为一份摘要,token 缩减到原来的约 12%。哪些内容 compact 后丢失:

数仓场景最痛的点:对话中说的"这次用 OVERWRITE 模式"、"先忽略 field_a 字段" → compact 后全忘;SKILL 文件读了一半,compact 后前几个 step 的内容没了 → Claude 重复询问;自测跑出来 50 行结果,加上血缘查询的几十行表结构 → context 很快膨胀到 compact 阈值。

四、五层防御体系(从简单到复杂)

第一层:写死进 CLAUDE.md(立即可用)

机制:项目根目录 .claude/CLAUDE.md 每次 compact 后从磁盘重新注入,是最可靠的持久化位置。将当前迭代的关键信息写入,格式建议:

# 当前迭代状态(每次迭代手动更新)## 正在开发- 表:db_a.dws_table_a
 版本:V1.0
 node_id:1000000001
 状态:ETL开发阶段(Step 3/8)

## 本次迭代约束- 禁止修改:dwd_table_b(已上线,只读)
 分区字段:partition_dt(格式 yyyyMMdd,不是 dt)
 amount 字段单位:千元(不是元)

## 数仓全局规范
 建表:分区字段必须是 partition_dt string
 禁止:SELECT *UPDATE/DELETEWHERE
 金额字段用 DECIMAL(20,4),不用 DOUBLE
 INSERT 必须带 PARTITION 子句

操作规则:进入新迭代时,更新"正在开发"和"本次迭代约束"两节;上线后清空"本次迭代约束";全局规范长期保留,控制在 100 行以内。

第二层:Auto Memory 自动积累(已在运行)

机制:Claude 自动将跨会话发现写入 ~/.claude/projects//memory/MEMORY.md,每次 compact 后重新注入。数仓场景下主动触发 Claude 写记忆的时机:"这张表的 amount 字段单位是千元,请记住";"field_a 在特定场景下会为空,请记住这个踩坑";"本次 V1.0 的关键变更是 field_b 逻辑调整,请记住"。Claude 会自动写入 MEMORY.md,下次会话或 compact 后自动恢复。

第三层:hooks 自动验证(核心防御,解决"忘了检查"的问题)

这是解决"每次写完 SQL 自动检查"的关键机制。

配置文件位置

数仓项目根目录/
└── .claude/
    ├── settings.json          ← hooks 在这里配置
    ├── CLAUDE.md              ← 数仓规范上下文
    └── hooks/
        ├── validate_sql.sh          ← SQL 规范自动检查
        ├── block_dangerous_ddl.sh   ← 危险 DDL 拦截
        └── inject_context.sh        ← compact 后重注入上下文

settings.json 完整配置

{"hooks": {"PostToolUse": [{"matcher": "Write|Edit","hooks": [{"type": "command","command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/validate_sql.sh","timeout": 60,"statusMessage": "检查 SQL 规范..."}]}],"PreToolUse": [{"matcher": "Bash","hooks": [{"type": "command","command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block_dangerous_ddl.sh"}]}],"SessionStart": [{"matcher": "compact","hooks": [{"type": "command","command": "cat \"$CLAUDE_PROJECT_DIR\"/.claude/context/dw_conventions.md","statusMessage": "重注入数仓规范..."}]}],"Stop": [{"hooks": [{"type": "prompt","prompt": "检查用户要求的所有任务是否都已完成。如果还有未完成项,返回提示但不要重新开始。检查 stop_hook_active 是否为 true,如是则直接 exit。","model": "claude-haiku-4-5-20251001"}]}]}}

SQL 规范自动检查脚本

.claude/hooks/validate_sql.sh:

#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo"$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('tool_input',{}).get('file_path',''))" 2>/dev/null)

# 只处理 .sql 文件
[[ "$FILE_PATH" != *.sql ]] && exit 0
[[ -z "$FILE_PATH" ]] && exit 0

SQL=$(cat "$FILE_PATH" 2>/dev/null)
[[ -z "$SQL" ]] && exit 0

ERRORS=()

# 规范1:禁止 SELECT *echo "$SQL" | grep -iqE 'SELECT\s+\*' && ERRORS+=("CRITICAL: 发现 SELECT *,必须明确列名")

# 规范2:INSERT 必须带 PARTITIONif echo "$SQL" | grep -iqE 'INSERT\s+(INTO|OVERWRITE)'; thenecho "$SQL" | grep -iqE 'PARTITION\s*\(' || ERRORS+=("CRITICAL: INSERT 缺少 PARTITION 子句")
fi# 规范3:DOUBLE 类型金额echo "$SQL" | grep -iqE '\bDOUBLE\b' && ERRORS+=("WARNING: 金额字段建议用 DECIMAL(20,4),不用 DOUBLE")

# 规范4:UPDATE/DELETE 必须有 WHEREif echo "$SQL" | grep -iqE '\b(UPDATE|DELETE)\b'; thenecho "$SQL" | grep -iqE '\bWHERE\b' || ERRORS+=("CRITICAL: UPDATE/DELETE 缺少 WHERE 条件")
fiif [ ${#ERRORS[@]} -gt 0 ]; thenecho "=== SQL 规范检查失败:$FILE_PATH ===" >&2
  for err in"${ERRORS[@]}"; doecho "  $err" >&2
  doneexit 2
fiecho "SQL 规范检查通过: $(basename $FILE_PATH)" >&2
exit 0

危险 DDL 拦截脚本

.claude/hooks/block_dangerous_ddl.sh:

#!/bin/bash
INPUT=$(cat)
CMD=$(echo"$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('tool_input',{}).get('command',''))" 2>/dev/null)

# 拦截生产表 DROP/TRUNCATE(放行 _dev/_test/_stg 后缀)if echo "$CMD" | grep -iqE '\b(DROP\s+TABLE|TRUNCATE\s+TABLE)\b'; thenif ! echo "$CMD" | grep -qiE '(_dev|_test|_stg)\b'; thenecho "BLOCKED: 检测到生产表 DROP/TRUNCATE 操作,请确认表名是否正确" >&2
    exit 2
  fifiexit 0

hook 通信协议关键规则

关键陷阱:阻断必须用 exit 2,用 exit 1 不会阻止 Claude 继续执行。

第四层:subagents 做上下文隔离(防 context 膨胀)

核心原则:把"高 token 消耗但结果只需要摘要"的操作放到 subagent 的独立 context 中执行。

数仓场景适合下放 subagent 的操作

创建 subagent 文件

.claude/agents/sql-validator.md(SQL 语法验证):

---
name: sql-validator
description: ODPS/MaxCompute SQL 语法验证与规范检查专用 agent。当用户生成或修改 SQL 文件后需要验证时调用。在独立 context 运行,防止大量验证日志污染主对话。
tools: Read, Bash, Grep, Glob
model: haiku
permissionMode: dontAsk
---

你是数仓 SQL 规范专家,只做验证,不修改文件。

验证项(按优先级):
1. SELECT * 禁止
2. INSERT 必须带 PARTITION
3. 字段用 snake_case 命名
4. 金额字段用 DECIMAL 不用 DOUBLE
5. 多表 JOIN 必须有 ON 条件
6. 笛卡尔积风险检测

输出格式:
- 状态:PASS / FAIL
- 问题列表(CRITICAL / WARNING / INFO)
- 修改建议(具体到行号)

不超过 50 行,只返回结构化报告。

.claude/agents/dw-explorer.md(防止血缘查询撑爆主 context):

---
name: dw-explorer
description: 数仓结构探索 agent。当需要大量读取表结构、DDL、字段信息、血缘关系时自动调用,避免大量文件内容污染主 context。只读,不修改任何文件。
tools: Read, Glob, Grep, Bash
model: haiku
permissionMode: dontAsk
---

你是数仓探索专家,只读操作。

当被调用时:
1. 读取指定表的 DDL、字段信息、分区策略
2. 分析上下游血缘(一层)
3. 识别关键字段口径(金额、日期、状态类字段)

输出:不超过 80 行的结构化摘要,包含:
- 表基本信息(层级、粒度、分区策略)
- 核心字段定义(含口径说明)
- 上下游血缘(只列表名,不展开内容)
- 发现的特殊口径或踩坑点

使用方式

在主对话中自然触发:

用 dw-explorer 分析 db_a.dwd_table_a 的结构

对刚生成的 insert_dws_table_a.sql 用 sql-validator 验证

或强制指定(避免 Claude 自行判断):

@"sql-validator (agent)" 验证 path/to/insert.sql

第五层:SKILL 文件改造(减少 context 消耗)

当前的问题:每次调用 SKILL 文件(01~08.md),内容全部加载进主 context,加速 compact 触发。改造方向:把 SKILL 文件的"执行步骤"提炼成 subagent 指令,subagent 内部读完整 SKILL 文件,主 context 只接收结果摘要;用 path-scoped rules 替代 SKILL 文件中的规范章节,按需加载:

---
# .claude/rules/etl-rules.md
paths:
  - "**/*insert*.sql"
  - "**/*_di.sql"
  - "**/*_df.sql"
---

# ETL 开发规范(按文件路径自动加载)
- 必须有 partition_dt 分区
- INSERT OVERWRITE 前检查分区是否已存在
- 不允许跨库 JOIN

SKILL 文件保持现状,但改变调用方式:不要在主对话中直接触发,而是启动一个 subagent 来读 SKILL 文件执行 ETL,主对话只接收最终产出的 SQL 文件路径。

五、可行落地方案:数仓 Harness 架构

先让我们看一下整体的架构设计(整体架构图):

这张架构图的核心逻辑是职责分层:不同类型的工作交给最合适的机制去做,而不是全部压在 Claude 的推理循环里。

持久化层解决的是"失忆"问题,任何临时口头说的约束,compact 后都会消失;但写进 .claude/CLAUDE.md 的内容,每次会话启动和 compact 后都会从磁盘重新注入------这是整套方案里最简单也最可靠的一层。

Harness 层(Hooks) 解决的是"规范靠记忆"的问题,PostToolUse hook 在每次写 .sql 文件后确定性触发,不依赖 Claude 有没有记住规范要求;违规时 exit 2 强制阻断,Claude 必须修正后才能继续,规范遵守率从 70%80% 提升到 95%+。

Subagent 层解决的是"context 被撑满"的问题,血缘查询、23 项自测、数据比对这类操作会产生大量 token,放到独立 context 的 subagent 里执行,主会话只接收一份摘要,compact 触发频率预计降低 50%70%。

三层机制分工明确,互不干扰:Hooks 处理"写动作触发的规范检查",subagent 处理"读操作的 context 隔离",CLAUDE.md 处理"跨会话状态持久化"。为了配合这套架构落地,数仓的研发流程可以拆解为 8 个大的步骤,流程图如下:

从 Harness 架构的视角来看,8 个步骤可以按"对 context 的影响"分成两类:一类是直接在主会话处理 (内容量有限,context 压力低):需求分析(读 PRD)、技术设计(写规范说明)、SR 导入(生成配置)、SLA/DQC(生成规则)。另一类必须通过 Harness 机制处理(否则会加速 compact 或规范失控):ETL 开发每次写 .sql 文件,PostToolUse hook 自动触发规范检查,不依赖人工提醒;自测时 23 条 SQL 的执行结果体量大,交给 data-quality-checker subagent 隔离,主会话只收 PASS/FAIL 摘要;数据比对时两表样本数据量大,交给 data-comparator subagent 隔离;性能优化时血缘 + 多层 DDL 每次 500~3000 tokens,交给 dw-explorer subagent 隔离。这种分工不是把步骤拆开独立执行,而是在同一个工作流里,让每个步骤以最合适的方式运行------context 压力小的步骤留在主会话保持流畅,context 压力大的步骤通过 subagent 隔离保持干净,规范检查通过 hook 自动执行不需要人工干预。

六、基于 SKILL 规范的数仓工作流设计

数仓 8 步 SKILL 规范(需求分析→技术设计→ETL→自测→数据比对→SR 导入→性能优化→SLA/DQC)天然对应 Harness 的三层机制。核心思路是:SKILL 文件不变,改变调用方式------主对话只读规范结论,实际执行由 subagent 或 hook 完成。

各步骤推荐提示词与工作流

Step 1:需求分析

推荐提示词:

用 dw-explorer subagent 先读取上游表结构(只返回摘要),
然后按需求分析规范生成:
1. 需求摘要(≤5行)
2. 表字段口径草稿
3. 待确认问题清单(按优先级排序)
需求文档 URL:[粘贴PRD链接]

Hook 配合:SessionStart 注入当前迭代约束(版本号/表名/禁止修改的表)。

Step 2:技术设计

推荐提示词:

基于上一步确认的需求,按 OneData 规范完成技术设计:
 表名:[按 层级_域_主题_粒度_周期 格式命名]
 粒度:[描述]
 分区:partition_dt string(格式 yyyyMMdd)
 禁止:任何与上游不一致的字段命名
输出 OneData 建模说明,不超过 60

CLAUDE.md 写入(设计完成后手动更新):

## 当前迭代技术设计决策- 表名:db_a.dws_table_a
- 主键:order_no + partition_dt
- 特殊约束:amount 字段继承上游千元单位,不做转换

Step 3:ETL 开发

这是 Harness 工程价值最高的步骤,PostToolUse hook 在每次 SQL 文件保存时自动触发。

推荐提示词:

按 ETL 开发规范生成建表 DDL + Insert SQL- 建表文件:ddl_[表名].sql
- 插入文件:insert_[表名].sql
- 要求:INSERT 用 OVERWRITE 模式,PARTITION 子句必须包含 partition_dt
- 金额字段:DECIMAL(20,4),单位继承上游(千元)
生成完毕后,用 sql-validator subagent 验证两个文件

Hook 自动执行(无需手动触发):每次写入 .sql 文件 → PostToolUse hook 自动运行规范检查。若发现 SELECT * 或缺少 PARTITION → 返回 exit 2,Claude 收到错误自动修正。

Step 4:自测

推荐提示词(防止自测结果撑爆主 context):

用 data-quality-checker subagent 对 [表名] 执行 23 项标准自测,
bizdate = [日期]
补充口径约束:[如"is_perform=1 只取履约订单"]
只返回:PASS/FAIL 汇总 + FAIL 项详情(≤50行),不返回原始 SQL 执行结果

效果:23 条 SQL 的执行结果全在 subagent context 里,主对话只收到一份摘要报告。

Step 5:数据比对

推荐提示词:

用 data-comparator subagent 对比:
 新表:[新表名] partition_dt = [日期]
 参考表:[旧表名/线上表]
 比对字段:[核心金额字段列表]
 容差:≤ 0.01%(金额类)
只返回:差异超过容差的字段列表 + 差值,不返回全量对比数据

Step 6:SR数据库导入

推荐提示词(自动生成最优数据库建表参数):

用 dw-sr SKILL生成建表任务, 先查以下表的 DDL 和一层上下游血缘(只返回摘要):
- 源表:[ODPS表名]
- 目标表:[SR表名]

然后基于 DDL 摘要,分析当前 SR 同步任务的配置风险:
1. 字段类型是否有精度丢失风险(DECIMAL/DOUBLE → DECIMAL(38,18))
2. Key 字段选择是否合理(重复率是否过高导致 DUPLICATE KEY 膨胀)
3. 分区数量是否合理(partition_live_number 与下游查询窗口是否匹配)
4. DISTRIBUTED BY HASH 的 bucket 数与数据量是否匹配
5. 是否有 DATETIME 字段在 SR 侧用了 VARCHAR 存储(会导致时间过滤走全表扫描)

输出同步任务配置建议(按风险高低排序),不超过 20 行。
每条格式:[风险等级 高/中/低] 问题描述 → 建议修改方式

Step 7:性能优化

推荐提示词(防止血缘查询撑爆主 context):

用 dw-explorer subagent 先查 [表名] 的一层上下游血缘和 DDL(只返回摘要),
然后分析当前 Insert SQL 的性能瓶颈:
1. 是否有全表扫描
2. 是否有笛卡尔积风险
3. 是否可以用 MAP JOIN 替代 HASH JOIN
输出优化建议(按收益排序),不超过 30

Step 8:SLA/DQC

推荐提示词:

按 SLA/DQC 规范为 [表名] 生成 9 类 DQC 规则:
 完整性:主键非空、分区数据量
 准确性:核心金额字段与上游比对(容差 0.01%)
 一致性:is_perform 与 perform_flag 联动逻辑
 时效性:产出时间 SLA ≤ 次日 8:00
输出 DQC 配置 JSON,可直接使用

SKILL 调用方式改造(减少主 context 消耗)

当前问题:触发 SKILL 时,SKILL 文件全文(约 10KB)加载进主 context,加速 compact。改造方向:

核心原则:主对话只接收决策级信息(PASS/FAIL、差异字段、优化方案),不接收过程数据(SQL 执行结果、原始血缘列表、完整 DDL 内容)。

精准对话流设计:控制AI思考的艺术

文章中多次提到 /dw-etl、/dw-自测 等命令,这是数仓研发全流程 SKILL 套件的核心触发词,每个命令对应一个封装了规范、产出格式和工具调用的标准化执行单元。核心理念:把每次都要重复讲的要求写进 SKILL,把每次都怕忘的检查点写进 SKILL,把每次都需要的结构化输出也写进 SKILL------这样谁来做需求都行,底座是一致的。

8 个 SKILL 命令一览

以 /dw-etl 为例:一条命令封装了什么?ETL 开发(Step 3)是 Harness 工程价值最高的步骤,SKILL 文件内封装了:① 规范内容(写死,不靠记忆):建表规范:分区字段必须是 partition_dt STRING(格式 YYYYMMDD);金额字段:DECIMAL(26,4),不用 DOUBLE;INSERT 模式:必须使用 INSERT OVERWRITE,必须带 PARTITION 子句;禁止:SELECT *、跨库 JOIN(非血缘关系)。② 产出格式(结构化,不走样):

输出文件:
├── ddl_[表名].sql      ← ODPS 建表语句(含注释三段式、生命周期配置)
├── insert_[表名].sql   ← ODPS Insert SQL(含分区裁剪、JOIN 规范)
└── ddl_sr_[表名].sql   ← SR 建表语句(Key 列顺序、DECIMAL 精度)

③ 自动护栏(Hook 配合):每次 .sql 文件写入后,PostToolUse hook 自动执行 validate_sql.sh;发现 SELECT * 或缺少 PARTITION → exit 2 阻断,Claude 强制修正。④ subagent 卸载(防 context 膨胀):"生成完毕后,用 sql-validator subagent 验证两个文件";验证结果全在 subagent context 里,主对话只收到"PASS/FAIL + 问题列表"。综合效益数据:

七、落地步骤

步骤一:项目级上下文持久化。在数仓项目目录下创建 .claude/CLAUDE.md,写入当前迭代状态。每次新迭代开始时更新"正在开发"和"本次迭代约束"两节,上线后清空约束节。全局规范永久保留,控制在 100 行以内。

步骤二:配置 hooks 自动验证。创建 .claude/settings.json + hooks/ 目录,配置 PostToolUse hook(每次写 .sql 后自动规范检查)和 PreToolUse hook(拦截危险 DDL)。效果:不需要每次手动说"帮我检查 SQL 规范",hook 在 Harness 层自动触发,不占用 Claude 推理资源。

步骤三:创建 subagents 隔离高 token 操作。创建三个核心 subagent 文件:sql-validator.md、dw-explorer.md、data-quality-checker.md。将 Step 4/5/7 的执行全部下放,预计主 context compact 频率降低 50%~70%。

八、Harness 工程能解决的核心问题

这一节是整个方案的出发点,也是对"为什么要这么做"的直接回答。

数仓 AI 开发当前的本质瓶颈:语义理解

在实际数仓 AI 开发中,技术能力不是瓶颈,语义理解才是。需求理解偏差占总返工的 40%~60%,大约 40%~50% 的工作时间花在理解需求和与业务核对口径上。精准血缘探查:准确率提升显著,远超传统方式。这两个观察揭示了同一个问题:AI 在数仓场景的不准确,根本原因不是"不会写 SQL",而是"不理解业务语义"------不知道这张表的 amount 单位是千元还是元,不知道 is_perform=1 在这个版本里意味着什么,不知道某字段在特定场景下会为空。

语义 × 数据 = 准确率

数仓 AI 开发的准确率,可以用一个公式来理解:

准确率 = 语义理解深度 × 数据规范覆盖度

结论:Harness 工程的本质,是把"语义"和"规范"从不可靠的 LLM 记忆中,迁移到确定性的 hooks + 持久化文件里,从而让 语义 × 规范 = 准确率 这个等式两边的变量都变得稳定。

具体能解决的四类问题

问题一:字段口径遗忘导致的计算错误

现状:对话开始时告知了"amount 字段单位是千元",compact 后 Claude 忘了,生成的 SQL 把千元当元用,导致数据差 1000 倍。Harness 解法:字段口径写进 .claude/CLAUDE.md(compact 后重新注入);踩坑经验写进 Auto Memory(跨会话持久化);结果:这类错误从"时常发生"降到"基本不出现"。

问题二:需求理解偏差导致的返工

现状:需求文档描述的是"用户视角的 GMV",但 AI 生成了"交易视角的 GMV",两者口径不同,数据对不上,需要返工。Harness 解法:需求分析的产出(口径草稿 + 待确认问题清单)写进 CLAUDE.md 的迭代约束节;Stop hook 检查任务完整性,确认待确认问题清单已全部明确后才放行;结果:需求一次交付通过率从约 50% 提升到 90%。

问题三:SQL 规范执行不一致

现状:规范文档写了"INSERT 必须带 PARTITION",但 Claude 有时会忘,或在 compact 后规范内容被清除后生成不合规的 SQL。Harness 解法:PostToolUse hook 在每次写 .sql 文件后自动执行规范检查,不依赖 Claude 记忆;违规时 exit 2 返回错误,Claude 强制修正后才能继续;结果:规范执行率从"依赖 LLM 记忆的 70%~80%"提升到"hook 强制的 95%+"。

问题四:大型需求开发中的 context 耗尽

现状:复杂需求(大型宽表、大量下游血缘)开发过程中,血缘查询 + 自测结果 + SKILL 文件内容堆满 context,compact 触发后丢失关键约束,Claude 开始犯低级错误。Harness 解法:血缘查询 → dw-explorer subagent(独立 context,只返回摘要);23 项自测 → data-quality-checker subagent(独立 context,只返回 PASS/FAIL 报告);SKILL 文件内容 → subagent 内部读取,主 context 只接收产出的 SQL 文件路径;结果:主 context compact 频率预计降低 50%~70%。

与传统数仓 AI 开发方式的对比

从"AI 帮我写代码"到"AI 嵌入研发流水线"

Harness 工程的最终目标,不是让 Claude 更聪明,而是让整个研发流水线更可靠。Claude(LLM)负责:理解需求、设计方案、生成代码------这些需要语义理解的事;Harness(hooks)负责:规范检查、危险拦截、任务完整性验证------这些需要确定性执行的事;Subagents 负责:大量文件读取、血缘查询、自测执行------这些会消耗大量 context 的事;CLAUDE.md + Memory 负责:字段口径、迭代约束、踩坑经验------这些需要跨会话持久化的事。这四层分工,让数仓 AI 开发从"对话驱动的一次性辅助"升级为"规则嵌入的流水线自动化"。

往期回顾

1.BP Claw 破解 AI 编码输入难题 ------FlinkSpec 需求智能化实践|得物技术

2.基于 Harness + SDD + 多仓管理模式的 AI 全栈开发实践|得物技术

3.通用 AI Agent 驱动网关路由安全审计实践|得物技术

4.AI驱动:从运营行为到自动化用例的智能化实践|得物技术

5.生成式召回在得物的落地技术分享与思考

文 /丹克

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。 )

2.基于 Harness + SDD + 多仓管理模式的 AI 全栈开发实践|得物技术

3.通用 AI Agent 驱动网关路由安全审计实践|得物技术

4.AI驱动:从运营行为到自动化用例的智能化实践|得物技术

5.生成式召回在得物的落地技术分享与思考

文 /丹克

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

Claude Code Harness 工程:数仓侧落地方案|得物技术

作者 得物技术
2026年5月21日 09:56

一、AI Coding 现状与痛点:为什么需要 Harness

当前使用情况

得物离线数仓各小组已基本完成 AI Coding 工具的覆盖,主力工具为 Claude Code,辅以数据平台的 IDE 插件,应对重复性工作时效率提升明显。

核心痛点

尽管整体提效已显现,但团队在实际使用中暴露出三类结构性痛点。

痛点一:AI 不记得上下文约束,开发过程中反复"失忆"。会话开始时告知了"金额字段单位是千元",对话进行到一半后 AI 忘了,生成的 SQL 把千元当元用,导致数据差了 1000 倍。这不是偶发问题,而是 Claude Code 的 context compact 机制(上下文压缩)的系统性限制:当对话 token 接近上限(约 95%)时,历史内容被自动压缩为摘要,临时口头说的约束全部丢失。

痛点二:规范执行不稳定,靠记忆兜底的部分最容易出问题。OneData 命名规范、注释三段式、INSERT 必须带 PARTITION 子句……这些规范大家都知道。在项目工期紧张时,人工规范遵守率降至 60%~70%,AI 靠 prompt 记忆的规范遵守率也只有 70%~80%。真正需要的是:把规范从"LLM 记忆中的指导性内容"变成"每次执行时强制检查的护栏"。

痛点三:大型需求开发中,context 很快被撑满,越到后期 AI 越不可靠。复杂需求(大型宽表、大量下游血缘)的典型开发过程:

血缘查询结果(500~3000 tokens) 自测 23 条 SQL 执行结果(5000~15000 tokens) SKILL 规范文件内容(~10000 tokens) 数据比对两表样本(大量行)= context 迅速膨胀 → compact 触发 → 关键约束遗忘 → AI 开始犯低级错误

核心矛盾:越是复杂的需求,越依赖 AI;但越复杂的需求,context 越容易撑满,AI 越容易"失忆"。

从"随手问 AI"到"把能力封装起来"

针对上述痛点,我们沿用了一条反复验证的结论:规范执行是人的短板、AI 的长板;业务判断是 AI 的短板、人的长板。Harness 工程的目标,就是把"执行层"的不稳定因素系统性地消掉:把规范写进 hooks,不再靠 AI 记忆,每次写 SQL 文件后自动触发检查;把迭代约束写进持久化文件,compact 后自动重新注入,不再靠临时口头说;把高 token 操作隔离到 subagent,主 context 只接收摘要,不被过程数据撑满。

二、先搞清楚"Harness"是什么

在 Claude Code 的 update-config skill 描述中有一句话:"Automated behaviors require hooks configured in settings.json — the harness executes these, not Claude"。

Harness = Claude Code 的宿主运行框架,即 Claude Code 客户端本身这个"工具链容器"。它:管理 context window 生命周期;在 LLM 推理循环之外确定性地执行 hooks;协调 subagents 的生命周期;不依赖模型判断,直接执行配置的自动化行为。区别总结:

三、核心问题:compact 到底丢掉什么?

每次数仓开发 context 接近满时,auto-compact 触发(默认 95% 时触发),会把整个对话历史替换为一份摘要,token 缩减到原来的约 12%。哪些内容 compact 后丢失:

数仓场景最痛的点:对话中说的"这次用 OVERWRITE 模式"、"先忽略 field_a 字段" → compact 后全忘;SKILL 文件读了一半,compact 后前几个 step 的内容没了 → Claude 重复询问;自测跑出来 50 行结果,加上血缘查询的几十行表结构 → context 很快膨胀到 compact 阈值。

四、五层防御体系(从简单到复杂)

第一层:写死进 CLAUDE.md(立即可用)

机制:项目根目录 .claude/CLAUDE.md 每次 compact 后从磁盘重新注入,是最可靠的持久化位置。将当前迭代的关键信息写入,格式建议:

# 当前迭代状态(每次迭代手动更新)## 正在开发- 表:db_a.dws_table_a
 版本:V1.0
 node_id:1000000001
 状态:ETL开发阶段(Step 3/8)

## 本次迭代约束- 禁止修改:dwd_table_b(已上线,只读)
 分区字段:partition_dt(格式 yyyyMMdd,不是 dt)
 amount 字段单位:千元(不是元)

## 数仓全局规范
 建表:分区字段必须是 partition_dt string
 禁止:SELECT *UPDATE/DELETEWHERE
 金额字段用 DECIMAL(20,4),不用 DOUBLE
 INSERT 必须带 PARTITION 子句

操作规则:进入新迭代时,更新"正在开发"和"本次迭代约束"两节;上线后清空"本次迭代约束";全局规范长期保留,控制在 100 行以内。

第二层:Auto Memory 自动积累(已在运行)

机制:Claude 自动将跨会话发现写入 ~/.claude/projects//memory/MEMORY.md,每次 compact 后重新注入。数仓场景下主动触发 Claude 写记忆的时机:"这张表的 amount 字段单位是千元,请记住";"field_a 在特定场景下会为空,请记住这个踩坑";"本次 V1.0 的关键变更是 field_b 逻辑调整,请记住"。Claude 会自动写入 MEMORY.md,下次会话或 compact 后自动恢复。

第三层:hooks 自动验证(核心防御,解决"忘了检查"的问题)

这是解决"每次写完 SQL 自动检查"的关键机制。

配置文件位置

数仓项目根目录/
└── .claude/
    ├── settings.json          ← hooks 在这里配置
    ├── CLAUDE.md              ← 数仓规范上下文
    └── hooks/
        ├── validate_sql.sh          ← SQL 规范自动检查
        ├── block_dangerous_ddl.sh   ← 危险 DDL 拦截
        └── inject_context.sh        ← compact 后重注入上下文

settings.json 完整配置

{"hooks": {"PostToolUse": [{"matcher": "Write|Edit","hooks": [{"type": "command","command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/validate_sql.sh","timeout": 60,"statusMessage": "检查 SQL 规范..."}]}],"PreToolUse": [{"matcher": "Bash","hooks": [{"type": "command","command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block_dangerous_ddl.sh"}]}],"SessionStart": [{"matcher": "compact","hooks": [{"type": "command","command": "cat \"$CLAUDE_PROJECT_DIR\"/.claude/context/dw_conventions.md","statusMessage": "重注入数仓规范..."}]}],"Stop": [{"hooks": [{"type": "prompt","prompt": "检查用户要求的所有任务是否都已完成。如果还有未完成项,返回提示但不要重新开始。检查 stop_hook_active 是否为 true,如是则直接 exit。","model": "claude-haiku-4-5-20251001"}]}]}}

SQL 规范自动检查脚本

.claude/hooks/validate_sql.sh:

#!/bin/bash
INPUT=$(cat)
FILE_PATH=$(echo"$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('tool_input',{}).get('file_path',''))" 2>/dev/null)

# 只处理 .sql 文件
[[ "$FILE_PATH" != *.sql ]] && exit 0
[[ -z "$FILE_PATH" ]] && exit 0

SQL=$(cat "$FILE_PATH" 2>/dev/null)
[[ -z "$SQL" ]] && exit 0

ERRORS=()

# 规范1:禁止 SELECT *echo "$SQL" | grep -iqE 'SELECT\s+\*' && ERRORS+=("CRITICAL: 发现 SELECT *,必须明确列名")

# 规范2:INSERT 必须带 PARTITIONif echo "$SQL" | grep -iqE 'INSERT\s+(INTO|OVERWRITE)'; thenecho "$SQL" | grep -iqE 'PARTITION\s*\(' || ERRORS+=("CRITICAL: INSERT 缺少 PARTITION 子句")
fi# 规范3:DOUBLE 类型金额echo "$SQL" | grep -iqE '\bDOUBLE\b' && ERRORS+=("WARNING: 金额字段建议用 DECIMAL(20,4),不用 DOUBLE")

# 规范4:UPDATE/DELETE 必须有 WHEREif echo "$SQL" | grep -iqE '\b(UPDATE|DELETE)\b'; thenecho "$SQL" | grep -iqE '\bWHERE\b' || ERRORS+=("CRITICAL: UPDATE/DELETE 缺少 WHERE 条件")
fiif [ ${#ERRORS[@]} -gt 0 ]; thenecho "=== SQL 规范检查失败:$FILE_PATH ===" >&2
  for err in"${ERRORS[@]}"; doecho "  $err" >&2
  doneexit 2
fiecho "SQL 规范检查通过: $(basename $FILE_PATH)" >&2
exit 0

危险 DDL 拦截脚本

.claude/hooks/block_dangerous_ddl.sh:

#!/bin/bash
INPUT=$(cat)
CMD=$(echo"$INPUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('tool_input',{}).get('command',''))" 2>/dev/null)

# 拦截生产表 DROP/TRUNCATE(放行 _dev/_test/_stg 后缀)if echo "$CMD" | grep -iqE '\b(DROP\s+TABLE|TRUNCATE\s+TABLE)\b'; thenif ! echo "$CMD" | grep -qiE '(_dev|_test|_stg)\b'; thenecho "BLOCKED: 检测到生产表 DROP/TRUNCATE 操作,请确认表名是否正确" >&2
    exit 2
  fifiexit 0

hook 通信协议关键规则

图片

关键陷阱:阻断必须用 exit 2,用 exit 1 不会阻止 Claude 继续执行。

第四层:subagents 做上下文隔离(防 context 膨胀)

核心原则:把"高 token 消耗但结果只需要摘要"的操作放到 subagent 的独立 context 中执行。

数仓场景适合下放 subagent 的操作

图片

创建 subagent 文件

.claude/agents/sql-validator.md(SQL 语法验证):

---
name: sql-validator
description: ODPS/MaxCompute SQL 语法验证与规范检查专用 agent。当用户生成或修改 SQL 文件后需要验证时调用。在独立 context 运行,防止大量验证日志污染主对话。
tools: Read, Bash, Grep, Glob
model: haiku
permissionMode: dontAsk
---

你是数仓 SQL 规范专家,只做验证,不修改文件。

验证项(按优先级):
1. SELECT * 禁止
2. INSERT 必须带 PARTITION
3. 字段用 snake_case 命名
4. 金额字段用 DECIMAL 不用 DOUBLE
5. 多表 JOIN 必须有 ON 条件
6. 笛卡尔积风险检测

输出格式:
- 状态:PASS / FAIL
- 问题列表(CRITICAL / WARNING / INFO)
- 修改建议(具体到行号)

不超过 50 行,只返回结构化报告。

.claude/agents/dw-explorer.md(防止血缘查询撑爆主 context):


---
name: dw-explorer
description: 数仓结构探索 agent。当需要大量读取表结构、DDL、字段信息、血缘关系时自动调用,避免大量文件内容污染主 context。只读,不修改任何文件。
tools: Read, Glob, Grep, Bash
model: haiku
permissionMode: dontAsk
---

你是数仓探索专家,只读操作。

当被调用时:
1. 读取指定表的 DDL、字段信息、分区策略
2. 分析上下游血缘(一层)
3. 识别关键字段口径(金额、日期、状态类字段)

输出:不超过 80 行的结构化摘要,包含:
- 表基本信息(层级、粒度、分区策略)
- 核心字段定义(含口径说明)
- 上下游血缘(只列表名,不展开内容)
- 发现的特殊口径或踩坑点

使用方式

在主对话中自然触发:

用 dw-explorer 分析 db_a.dwd_table_a 的结构

对刚生成的 insert_dws_table_a.sql 用 sql-validator 验证

或强制指定(避免 Claude 自行判断):

@"sql-validator (agent)" 验证 path/to/insert.sql

第五层:SKILL 文件改造(减少 context 消耗)

当前的问题:每次调用 SKILL 文件(01~08.md),内容全部加载进主 context,加速 compact 触发。改造方向:把 SKILL 文件的"执行步骤"提炼成 subagent 指令,subagent 内部读完整 SKILL 文件,主 context 只接收结果摘要;用 path-scoped rules 替代 SKILL 文件中的规范章节,按需加载:

---
# .claude/rules/etl-rules.md
paths:
  - "**/*insert*.sql"
  - "**/*_di.sql"
  - "**/*_df.sql"
---

# ETL 开发规范(按文件路径自动加载)
- 必须有 partition_dt 分区
- INSERT OVERWRITE 前检查分区是否已存在
- 不允许跨库 JOIN

SKILL 文件保持现状,但改变调用方式:不要在主对话中直接触发,而是启动一个 subagent 来读 SKILL 文件执行 ETL,主对话只接收最终产出的 SQL 文件路径。

五、可行落地方案:数仓 Harness 架构

先让我们看一下整体的架构设计(整体架构图):

图片

这张架构图的核心逻辑是职责分层:不同类型的工作交给最合适的机制去做,而不是全部压在 Claude 的推理循环里。

持久化层解决的是"失忆"问题,任何临时口头说的约束,compact 后都会消失;但写进 .claude/CLAUDE.md 的内容,每次会话启动和 compact 后都会从磁盘重新注入——这是整套方案里最简单也最可靠的一层。

Harness 层(Hooks) 解决的是"规范靠记忆"的问题,PostToolUse hook 在每次写 .sql 文件后确定性触发,不依赖 Claude 有没有记住规范要求;违规时 exit 2 强制阻断,Claude 必须修正后才能继续,规范遵守率从 70%80% 提升到 95%+。

Subagent 层解决的是"context 被撑满"的问题,血缘查询、23 项自测、数据比对这类操作会产生大量 token,放到独立 context 的 subagent 里执行,主会话只接收一份摘要,compact 触发频率预计降低 50%70%。

三层机制分工明确,互不干扰:Hooks 处理"写动作触发的规范检查",subagent 处理"读操作的 context 隔离",CLAUDE.md 处理"跨会话状态持久化"。为了配合这套架构落地,数仓的研发流程可以拆解为 8 个大的步骤,流程图如下:

图片

从 Harness 架构的视角来看,8 个步骤可以按"对 context 的影响"分成两类:一类是直接在主会话处理(内容量有限,context 压力低):需求分析(读 PRD)、技术设计(写规范说明)、SR 导入(生成配置)、SLA/DQC(生成规则)。另一类必须通过 Harness 机制处理(否则会加速 compact 或规范失控):ETL 开发每次写 .sql 文件,PostToolUse hook 自动触发规范检查,不依赖人工提醒;自测时 23 条 SQL 的执行结果体量大,交给 data-quality-checker subagent 隔离,主会话只收 PASS/FAIL 摘要;数据比对时两表样本数据量大,交给 data-comparator subagent 隔离;性能优化时血缘 + 多层 DDL 每次 500~3000 tokens,交给 dw-explorer subagent 隔离。这种分工不是把步骤拆开独立执行,而是在同一个工作流里,让每个步骤以最合适的方式运行——context 压力小的步骤留在主会话保持流畅,context 压力大的步骤通过 subagent 隔离保持干净,规范检查通过 hook 自动执行不需要人工干预。

六、基于 SKILL 规范的数仓工作流设计

数仓 8 步 SKILL 规范(需求分析→技术设计→ETL→自测→数据比对→SR 导入→性能优化→SLA/DQC)天然对应 Harness 的三层机制。核心思路是:SKILL 文件不变,改变调用方式——主对话只读规范结论,实际执行由 subagent 或 hook 完成。

图片

各步骤推荐提示词与工作流

Step 1:需求分析

推荐提示词:

用 dw-explorer subagent 先读取上游表结构(只返回摘要),
然后按需求分析规范生成:
1. 需求摘要(≤5行)
2. 表字段口径草稿
3. 待确认问题清单(按优先级排序)
需求文档 URL:[粘贴PRD链接]

Hook 配合:SessionStart 注入当前迭代约束(版本号/表名/禁止修改的表)。

Step 2:技术设计

推荐提示词:

基于上一步确认的需求,按 OneData 规范完成技术设计:
 表名:[按 层级_域_主题_粒度_周期 格式命名]
 粒度:[描述]
 分区:partition_dt string(格式 yyyyMMdd)
 禁止:任何与上游不一致的字段命名
输出 OneData 建模说明,不超过 60

CLAUDE.md 写入(设计完成后手动更新):

## 当前迭代技术设计决策- 表名:db_a.dws_table_a
- 主键:order_no + partition_dt
- 特殊约束:amount 字段继承上游千元单位,不做转换

Step 3:ETL 开发

这是 Harness 工程价值最高的步骤,PostToolUse hook 在每次 SQL 文件保存时自动触发。

推荐提示词:

按 ETL 开发规范生成建表 DDL + Insert SQL- 建表文件:ddl_[表名].sql
- 插入文件:insert_[表名].sql
- 要求:INSERT 用 OVERWRITE 模式,PARTITION 子句必须包含 partition_dt
- 金额字段:DECIMAL(20,4),单位继承上游(千元)
生成完毕后,用 sql-validator subagent 验证两个文件

Hook 自动执行(无需手动触发):每次写入 .sql 文件 → PostToolUse hook 自动运行规范检查。若发现 SELECT * 或缺少 PARTITION → 返回 exit 2,Claude 收到错误自动修正。

Step 4:自测

推荐提示词(防止自测结果撑爆主 context):

用 data-quality-checker subagent 对 [表名] 执行 23 项标准自测,
bizdate = [日期]
补充口径约束:[如"is_perform=1 只取履约订单"]
只返回:PASS/FAIL 汇总 + FAIL 项详情(≤50行),不返回原始 SQL 执行结果

效果:23 条 SQL 的执行结果全在 subagent context 里,主对话只收到一份摘要报告。

Step 5:数据比对

推荐提示词:

用 data-comparator subagent 对比:
 新表:[新表名] partition_dt = [日期]
 参考表:[旧表名/线上表]
 比对字段:[核心金额字段列表]
 容差:≤ 0.01%(金额类)
只返回:差异超过容差的字段列表 + 差值,不返回全量对比数据

Step 6:SR数据库导入

推荐提示词(自动生成最优数据库建表参数):

用 dw-sr SKILL生成建表任务, 先查以下表的 DDL 和一层上下游血缘(只返回摘要):
- 源表:[ODPS表名]
- 目标表:[SR表名]

然后基于 DDL 摘要,分析当前 SR 同步任务的配置风险:
1. 字段类型是否有精度丢失风险(DECIMAL/DOUBLE → DECIMAL(38,18))
2. Key 字段选择是否合理(重复率是否过高导致 DUPLICATE KEY 膨胀)
3. 分区数量是否合理(partition_live_number 与下游查询窗口是否匹配)
4. DISTRIBUTED BY HASH 的 bucket 数与数据量是否匹配
5. 是否有 DATETIME 字段在 SR 侧用了 VARCHAR 存储(会导致时间过滤走全表扫描)

输出同步任务配置建议(按风险高低排序),不超过 20 行。
每条格式:[风险等级 高/中/低] 问题描述 → 建议修改方式

Step 7:性能优化

推荐提示词(防止血缘查询撑爆主 context):

用 dw-explorer subagent 先查 [表名] 的一层上下游血缘和 DDL(只返回摘要),
然后分析当前 Insert SQL 的性能瓶颈:
1. 是否有全表扫描
2. 是否有笛卡尔积风险
3. 是否可以用 MAP JOIN 替代 HASH JOIN
输出优化建议(按收益排序),不超过 30

Step 8:SLA/DQC

推荐提示词:

按 SLA/DQC 规范为 [表名] 生成 9 类 DQC 规则:
 完整性:主键非空、分区数据量
 准确性:核心金额字段与上游比对(容差 0.01%)
 一致性:is_perform 与 perform_flag 联动逻辑
 时效性:产出时间 SLA ≤ 次日 8:00
输出 DQC 配置 JSON,可直接使用

SKILL 调用方式改造(减少主 context 消耗)

当前问题:触发 SKILL 时,SKILL 文件全文(约 10KB)加载进主 context,加速 compact。改造方向:

图片

核心原则:主对话只接收决策级信息(PASS/FAIL、差异字段、优化方案),不接收过程数据(SQL 执行结果、原始血缘列表、完整 DDL 内容)。

精准对话流设计:控制AI思考的艺术

文章中多次提到 /dw-etl、/dw-自测 等命令,这是数仓研发全流程 SKILL 套件的核心触发词,每个命令对应一个封装了规范、产出格式和工具调用的标准化执行单元。核心理念:把每次都要重复讲的要求写进 SKILL,把每次都怕忘的检查点写进 SKILL,把每次都需要的结构化输出也写进 SKILL——这样谁来做需求都行,底座是一致的。

8 个 SKILL 命令一览

图片

以 /dw-etl 为例:一条命令封装了什么?ETL 开发(Step 3)是 Harness 工程价值最高的步骤,SKILL 文件内封装了:① 规范内容(写死,不靠记忆):建表规范:分区字段必须是 partition_dt STRING(格式 YYYYMMDD);金额字段:DECIMAL(26,4),不用 DOUBLE;INSERT 模式:必须使用 INSERT OVERWRITE,必须带 PARTITION 子句;禁止:SELECT *、跨库 JOIN(非血缘关系)。② 产出格式(结构化,不走样):

输出文件:
├── ddl_[表名].sql      ← ODPS 建表语句(含注释三段式、生命周期配置)
├── insert_[表名].sql   ← ODPS Insert SQL(含分区裁剪、JOIN 规范)
└── ddl_sr_[表名].sql   ← SR 建表语句(Key 列顺序、DECIMAL 精度)

③ 自动护栏(Hook 配合):每次 .sql 文件写入后,PostToolUse hook 自动执行 validate_sql.sh;发现 SELECT * 或缺少 PARTITION → exit 2 阻断,Claude 强制修正。④ subagent 卸载(防 context 膨胀):"生成完毕后,用 sql-validator subagent 验证两个文件";验证结果全在 subagent context 里,主对话只收到"PASS/FAIL + 问题列表"。综合效益数据:

图片

七、落地步骤

步骤一:项目级上下文持久化。在数仓项目目录下创建 .claude/CLAUDE.md,写入当前迭代状态。每次新迭代开始时更新"正在开发"和"本次迭代约束"两节,上线后清空约束节。全局规范永久保留,控制在 100 行以内。

步骤二:配置 hooks 自动验证。创建 .claude/settings.json + hooks/ 目录,配置 PostToolUse hook(每次写 .sql 后自动规范检查)和 PreToolUse hook(拦截危险 DDL)。效果:不需要每次手动说"帮我检查 SQL 规范",hook 在 Harness 层自动触发,不占用 Claude 推理资源。

步骤三:创建 subagents 隔离高 token 操作。创建三个核心 subagent 文件:sql-validator.md、dw-explorer.md、data-quality-checker.md。将 Step 4/5/7 的执行全部下放,预计主 context compact 频率降低 50%~70%。

八、Harness 工程能解决的核心问题

这一节是整个方案的出发点,也是对"为什么要这么做"的直接回答。

数仓 AI 开发当前的本质瓶颈:语义理解

在实际数仓 AI 开发中,技术能力不是瓶颈,语义理解才是。需求理解偏差占总返工的 40%~60%,大约 40%~50% 的工作时间花在理解需求和与业务核对口径上。精准血缘探查:准确率提升显著,远超传统方式。这两个观察揭示了同一个问题:AI 在数仓场景的不准确,根本原因不是"不会写 SQL",而是"不理解业务语义"——不知道这张表的 amount 单位是千元还是元,不知道 is_perform=1 在这个版本里意味着什么,不知道某字段在特定场景下会为空。

语义 × 数据 = 准确率

数仓 AI 开发的准确率,可以用一个公式来理解:

准确率 = 语义理解深度 × 数据规范覆盖度

图片

结论:Harness 工程的本质,是把"语义"和"规范"从不可靠的 LLM 记忆中,迁移到确定性的 hooks + 持久化文件里,从而让 语义 × 规范 = 准确率 这个等式两边的变量都变得稳定。

具体能解决的四类问题

问题一:字段口径遗忘导致的计算错误

现状:对话开始时告知了"amount 字段单位是千元",compact 后 Claude 忘了,生成的 SQL 把千元当元用,导致数据差 1000 倍。Harness 解法:字段口径写进 .claude/CLAUDE.md(compact 后重新注入);踩坑经验写进 Auto Memory(跨会话持久化);结果:这类错误从"时常发生"降到"基本不出现"。

问题二:需求理解偏差导致的返工

现状:需求文档描述的是"用户视角的 GMV",但 AI 生成了"交易视角的 GMV",两者口径不同,数据对不上,需要返工。Harness 解法:需求分析的产出(口径草稿 + 待确认问题清单)写进 CLAUDE.md 的迭代约束节;Stop hook 检查任务完整性,确认待确认问题清单已全部明确后才放行;结果:需求一次交付通过率从约 50% 提升到 90%。

问题三:SQL 规范执行不一致

现状:规范文档写了"INSERT 必须带 PARTITION",但 Claude 有时会忘,或在 compact 后规范内容被清除后生成不合规的 SQL。Harness 解法:PostToolUse hook 在每次写 .sql 文件后自动执行规范检查,不依赖 Claude 记忆;违规时 exit 2 返回错误,Claude 强制修正后才能继续;结果:规范执行率从"依赖 LLM 记忆的 70%~80%"提升到"hook 强制的 95%+"。

问题四:大型需求开发中的 context 耗尽

现状:复杂需求(大型宽表、大量下游血缘)开发过程中,血缘查询 + 自测结果 + SKILL 文件内容堆满 context,compact 触发后丢失关键约束,Claude 开始犯低级错误。Harness 解法:血缘查询 → dw-explorer subagent(独立 context,只返回摘要);23 项自测 → data-quality-checker subagent(独立 context,只返回 PASS/FAIL 报告);SKILL 文件内容 → subagent 内部读取,主 context 只接收产出的 SQL 文件路径;结果:主 context compact 频率预计降低 50%~70%。

与传统数仓 AI 开发方式的对比

图片

从"AI 帮我写代码"到"AI 嵌入研发流水线"

Harness 工程的最终目标,不是让 Claude 更聪明,而是让整个研发流水线更可靠。Claude(LLM)负责:理解需求、设计方案、生成代码——这些需要语义理解的事;Harness(hooks)负责:规范检查、危险拦截、任务完整性验证——这些需要确定性执行的事;Subagents 负责:大量文件读取、血缘查询、自测执行——这些会消耗大量 context 的事;CLAUDE.md + Memory 负责:字段口径、迭代约束、踩坑经验——这些需要跨会话持久化的事。这四层分工,让数仓 AI 开发从"对话驱动的一次性辅助"升级为"规则嵌入的流水线自动化"。

往期回顾

1.BP Claw 破解 AI 编码输入难题 ——FlinkSpec 需求智能化实践|得物技术

2.基于 Harness + SDD + 多仓管理模式的 AI 全栈开发实践|得物技术

3.通用 AI Agent 驱动网关路由安全审计实践|得物技术

4.AI驱动:从运营行为到自动化用例的智能化实践|得物技术

5.生成式召回在得物的落地技术分享与思考

文 /丹克

关注得物技术,每周三更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

BP Claw 破解 AI 编码输入难题 ——FlinkSpec 需求智能化实践|得物技术

作者 得物技术
2026年5月14日 13:30

一句话理解 BP Claw:它是 FlinkSpec 上游的 “AI 数据 BP”,把产品经理的非标 PRD 自动转化为 AI Coding 可消费的高质量需求文档。在深入阅读之前,先用一张表告诉你 BP Claw 能带来什么。

image.png

如果你的团队也在被以下问题困扰,这篇文章值得读完:AI Coding 效果不稳定,同样的框架、不同的需求,结果天差地别;需求沟通来回反复,一个指标口径要确认三四轮;PRD 质量参差不齐,开发阶段频繁踩雷、返工延期。BP Claw 的答案是:问题的根源在输入,解法在源头。

一、FlinkSpec:实时数仓的 AI 工程化底座

在深入 BP Claw 之前,我们先来看一个更大的图景 ——FlinkSpec。FlinkSpec 是实时数仓团队打造的 AI 工程化开发框架,它以 “需求驱动、制品为锚” 的理念,将一条实时数据链路的生命周期拆解为四个阶段,每个阶段都有严格的门控评估,确保交付质量。

FlinkSpec 全景架构

image.png

BP Claw 在 FlinkSpec 中的定位

在 FlinkSpec 的四个阶段中,需求澄清阶段是一切的起点。而需求澄清阶段的质量,取决于一个关键输入 ——PRD 文档。如果 PRD 质量不达标,后续的一切都是空中楼阁:需求定义模糊 → Intake 门控反复打回 → Define 阶段耗时翻倍;技术口径缺失 → Flink-SQL Agent 无法编码 → 频繁触发阻塞协议(BLOCK-N);验收标准不清 → Review 阶段发现问题 → 回退到 Define 重来。

image.png

BP Claw 解决实际问题案例

问题定义

商业化广告投放链路中,一次广告曝光往往由多个商家共同参与竞价。这种一对多的流量-主体映射关系,在指标聚合时会产生一个结构性的歧义:以"商家"为维度下钻时,同一次流量事件会被多个商家各自计入,导致商家侧明细加总后,与平台侧总量不等。这不是数据质量问题,而是两套在各自语义下都正确的口径共存在同一张宽表里,却没有被显式区分:impression_cnt(平台侧)去重到流量维度,一次曝光 = 1;impression_cnt(商家侧)展开到主体维度,N 个商家参与 = N。当下游消费方使用同一个字段名、却按不同语义聚合时,CTR、CVR、ROI 等衍生指标就会系统性失真。

为什么难以在开发阶段发现

实时数仓的指标开发通常以 FlinkSQL 为载体,开发侧看到的输入是 Kafka 事件流和维度表,字段语义的边界在建模时已经隐式确定。问题在于: FlinkSQL 层面的 GROUP BY 逻辑无法自动感知上游指标应该以哪个粒度聚合。如果流量事件里同时携带了 advertiser_id 列表,不同的展开方式对应完全不同的业务语义,而这一选择在代码里只是一行 UNNEST 或一个 JOIN 的差异。常规的 Code Review 能发现 SQL 逻辑错误,却很难发现"SQL 逻辑正确、但口径选择与业务预期不符"这类问题——因为两种写法都能跑通,数据差异在没有对比基准的情况下也不会报错。

BP Claw 的介入点

BP Claw 作为需求到编码之间的中间层,其核心能力是在指标定义阶段做口径显式化,而不是在开发完成后做数据校验。

具体做法是结合 OpenViking 知识库(团队历史建模文档、字段定义、历史踩坑记录)做语义召回,识别当前需求涉及的指标是否存在多粒度歧义,并在进入 FlinkSpec 之前,将以下约束显式写入需求文档:

image.png

这套约束作为 FlinkSpec 的输入,让 AI Coding 在生成 DDL 和 INSERT 逻辑时,直接基于已经对齐的语义边界,而不是从模糊描述中自行推断。

与通用 AI Coding 的差异

通用 AI Coding 工具在给定输入后,会选择一种"看起来合理"的实现——对于多商家指标,它会倾向于展开商家维度(因为需求里写了"按商家查看"),但不会主动判断这是否会导致平台侧口径膨胀。

BP Claw 的差异在于知识来源:它使用的不是通用语言模型对业务逻辑的推断,而是团队在同类建模问题上积累的历史决策和约束条件。对于"多商家指标是否需要分口径建模"这个问题,OpenViking 里已经有过明确结论,BP Claw 将其召回并作为强约束注入当前需求,而不是重新推断一次。

BP Claw 正是为了解决这个"源头问题"而生。它站在 FlinkSpec 的上游,确保进入 Define 阶段的 PRD 文档质量足够支撑后续的 AI 自动化编码。

二、产品设计:从痛点出发的解决方案

面向谁?解决什么?

image.png

设计哲学:贴合工作流,而非改造工作流

BP Claw 的设计遵循三条核心原则,原则一:嵌入现有场景——用户不需要打开新页面、学习新工具,直接在飞书群聊中 @ 机器人即可触发,和日常"拉群评审"的行为完全一致;原则二:产品经理零感知提效——产品经理只需要提交原始 PRD 文档,BP Claw 自动完成规范化转换,产品经理无需关心格式,产品经理收到的是结构化、高质量的评审群,而非一堆格式要求;原则三:不硬卡点,做参照系——PRD 评分是"参照",不是"阻塞",帮助团队建立质量意识,而非制造流程摩擦,逐步提升 PRD 质量基线,而非一刀切。

三、核心能力层

对实时数仓而言,一份好的 PRD 应该是什么样的?

在深入技术实现之前,我们先回答一个根本性问题:实时数仓对 PRD 文档的要求是什么?一份好的实时数仓 PRD,需要覆盖七大模块:

image.png

然而,产品经理提交的文档往往只覆盖了其中的 2-3 个模块,而且格式五花八门。这正是 BP Claw 要解决的核心问题 —— 需求转化。

能力一:智能需求转化(核心能力)

这是 BP Claw 最核心、最有技术含量的能力。它做的事情,本质上是 模拟一个经验丰富的数据 BP 的工作过程。

转化过程详解

image.png

转化的技术难点

如何处理常见的业务需求类型?我们总结了实时数仓中最常见的几类需求模式:

image.png

转化的核心原则:忠实于原文

原文有的信息进行结构化提取,按模板填充;原文没有的信息标记为待补充,绝不凭空捏造;原文有歧义的信息保留原始表述,同时标注歧义点;原文划删除线的标注为"本期不纳入,暂缓"。

能力二:PRD 质量评分(参照能力)

在完成需求转化后,BP Claw 会对标准化文档进行多维度质量评估,生成诊断报告。

五大评分维度

image.png

三条核心评分规则

规则一:技术口径一票否决——如果技术口径得分 < 15 分(满分 25),即使总分 ≥ 90,也标注为不可执行。原因:没有技术口径,FlinkSpec 的 Flink-SQL Agent 完全无法启动编码。规则二:验收标准缺失扣分——验收标准完全缺失,总分直接扣除 20 分;严重不足(≤5 分),扣除 10 分。原因:没有验收标准的需求,上线即翻车。规则三:溢出得分机制——某维度特别优秀可超出该维度满分,但总分上限仍为 100 分。原因:鼓励在关键维度上做到极致。

image.png

需要强调的是:PRD 评分不是硬卡点,不是流程阻塞。它是一面镜子,帮助团队看到当前 PRD 的质量水位,逐步建立质量意识。

能力三:自动拉群(工作流融合能力)

自动拉群看似简单,但它承载着一个重要的产品设计意图——降低落地阻碍。为什么做自动拉群?如果 BP Claw 只能生成文档,用户还需要手动拉群、手动 @ 人、手动转发文档,那使用体验就是割裂的。自动拉群让整个流程变成了"一步触发、全程自动":用户只需要在飞书群里 @ 机器人、@ 评审人,附上文档链接。BP Claw 自动完成:读取文档 → 生成标准化文档 → 质量评估 → 创建评审群 → 邀请成员 → 发送文档和诊断报告。评审人在新群里直接看到结构化的文档和质量报告,开始评审。这就是"贴合现有工作流"的具体体现——用户的行为没有变,但背后的效率翻了20倍。

四、技术难点与解决方案

省 Token 的技巧

在 AI 应用中,Token 消耗直接关系到成本和响应速度。BP Claw 在这方面做了多项优化。

技巧一:分段生成策略

对于包含 10 个以上指标的大文档,我们不会一次性生成完整文档(这会导致上下文溢出),而是采用分段生成:先创建文档骨架(业务背景 + 角色-页面-指标矩阵),逐个追加指标定义模块,每追加 5 个指标发送一次进度反馈,最后追加验收标准和用数链路。本质是把"一次大事务"拆成"多次小提交",即使中途失败也保留了已写入内容,不需要从头来。同时每段写入后向群聊发送进度消息(已生成 5/12 个指标),让用户有等待感知,不会误以为机器人卡死。收益:避免单次 API 调用超时,同时减少重复上下文传递的 Token 消耗。

技巧二:分层调用架构

BP Claw 采用编排者(动态调度,按需组合 Skill,职责清晰)模式,将核心逻辑拆分到三个独立 Skill 中:

image.png

每个 Skill 独立运行、独立管理上下文,避免了将所有逻辑放在同一个上下文中导致的 Token 膨胀。

技巧三:模板化 Prompt 设计

标准化文档的七大模块,每个模块都有严格定义的模板结构。通过模板约束 AI 的输出格式,避免了冗余输出:明确指定输出字段,不多不少;使用 Markdown 表格约束结构;通过 Few-Shot 示例引导输出风格。

稳定性保障:如何避免幻觉?

这是 BP Claw 最重要的技术挑战。AI 生成的文档如果出现"幻觉"(编造不存在的业务口径),后果比不生成还严重。

策略一:严格的忠实性约束

在 Skill 的核心 Prompt 中,我们设置了多层约束:

核心规则:忠实于源材料原文有的 → 结构化提取原文没有的 → 标记 ⚠️ 待补充绝不凭空发明业务定义绝不猜测技术口径

策略二:标记机制取代猜测

当 AI 遇到无法确定的信息时,不允许"猜测后继续",而是必须:明确标记为待补充;说明缺失的具体内容;给出建议补充的方向。这样做的好处是:用户看到的文档中,每一条确定的信息都是可信的。

策略三:质量评分的交叉验证

prd-quality-scorer 作为独立的评分 Skill,会对生成的文档进行"第二遍"审查。如果文档中存在明显的逻辑矛盾或不合理之处,评分报告中会直接指出。

策略四:参数校验与重试机制

• 调用飞书 API 前,强制校验所有必填参数• 文档生成失败 → 最多重试 3 次(间隔 2s → 5s → 10s 指数退避)• 3 次均失败 → 终止流程,发送明确的错误消息• 绝不降级生成"简化版"文档(不完整不如不生成)

打磨 Skill 的技巧与难点

难点一:如何让 AI 理解"实时数仓"的领域语义?普通的 LLM 并不理解"去重视图""Lookup Join""sink.properties.columns"这些领域概念。我们通过以下方式解决:领域知识注入(在 Skill 定义中嵌入实时数仓的核心概念解释);模板驱动(通过模板结构限定输出范围,减少 AI 的"自由发挥空间");Few-Shot 示例(提供真实的标准化 PRD 样例作为参考)。

难点二:如何处理超长文档?有些 PRD 文档超过 10000 字符、包含 15+ 指标,直接处理会导致 API 超时(默认 60 秒不够用)、Token 超限、输出截断。解决方案:显式设置 timeoutSeconds=1200(20 分钟);采用分段生成策略,每次只处理 3-5 个指标;实时进度反馈,让用户知道系统仍在工作。

难点三:如何协调多个 Skill 的执行顺序?BP Claw 作为"指挥家/编排者",需要严格控制 4 个步骤的执行顺序和依赖关系:

规则:严格串行,前序成功才执行后续STEP 1 成功 → STEP 2STEP 2 成功 → STEP 3STEP 3 成功或失败 → STEP 4(评分失败不阻塞)STEP 4 成功 → 完成任一关键步骤失败 → 立即终止,通知用户

为什么 STEP 3 失败不阻塞?因为质量评分是"参照"而非"卡点"。即使评分失败,标准化文档和评审群依然有价值。

五、与 FlinkSpec 的联动:全链路赋能

BP Claw → FlinkSpec 的价值传递

image.png

体验效果:PRD 质量提升对 AI Coding 的赋能

场景一:技术口径完整的 PRD——BP Claw 评分 ≥ 90 分,FlinkSpec Define 阶段一次通过,无 BLOCK,Flink-SQL Agent 直接编码无需人工干预,整体交付周期为天级 ⏱️。场景二:技术口径缺失的 PRD——BP Claw 评分 < 60 分,触发技术口径一票否决,FlinkSpec Define 阶段频繁触发阻塞协议,Flink-SQL Agent 无法编码需反复沟通,整体交付周期为周级。结论: BP Claw 每提升 10 分的 PRD 质量,FlinkSpec 的编码阶段效率约提升 30%。

六、落地运营:产品 + 运营 = 真正的落地

一个好的产品工具,如果没有运营手段的配合,是无法真正落地的。BP Claw 在落地过程中,我们采用了产品能力 + 运营能力双轮驱动的策略。

运营手段一:成熟度评分体系

我们建立了 域级 PRD 成熟度评分 机制,通过持续追踪各业务域的 PRD 质量水位,推动整体提升:

image.png

运营手段二:质量趋势追踪

通过持续收集每次 PRD 评分数据,建立质量趋势看板:按业务域维度追踪质量变化;识别高频缺失项,定向改进;月度质量复盘,表彰优秀案例。

运营手段三:最佳实践沉淀

将高分 PRD 案例沉淀为模板,形成可复用的知识资产:优秀案例库(≥ 90 分的 PRD 自动入库);改进指南(针对常见扣分项提供标准化的补充模板);新人培训(通过 BP Claw 评分报告快速上手 PRD 编写规范)。

七、快速上手

使用方法

只需一步:在飞书群聊中发送一条消息:

@商业化MOSS @评审人1 @评审人2 @评审人N.... 需求拉群 飞书PRD文档链接

image.png

然后等待 1-5 分钟,你将在新的评审群中看到:标准化 PRD 文档、质量诊断报告、改进建议清单。

注意事项

image.png

八、展望后续

本文为 FlinkSpec 系列之开篇,亦是这场工程化变革的序章。BP Claw 所立之处,不过链路之源。FlinkSpec 所图,乃以 AI 之力,将实时数仓从需求落地至验收上线的全程工序,熔铸为一套精密自洽、生生不息的智能工程体系。宏图未竟,后续系列将逐章揭幕,敬请期待!

往期回顾

1.基于 Harness + SDD + 多仓管理模式的 AI 全栈开发实践|得物技术

2.通用 AI Agent 驱动网关路由安全审计实践|得物技术

3.AI驱动:从运营行为到自动化用例的智能化实践|得物技术

4.生成式召回在得物的落地技术分享与思考

5.立正请站好:一个组件复用 Skill 的工程化实践|得物技术

文 /子宸

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

通用 AI Agent 驱动网关路由安全审计实践

作者 得物技术
2026年4月30日 09:57

本项目构建了一个网关路由 AI 安全审计系统,采用"通用 Agent + 业务 Skill"分层设计,增量日检/存量月检。落地 Open 网关路由越权漏洞检测流程,通过 AI 批量筛查 + 人工深度验证的人机协同模式,为大规模 API 安全审计提供了可复用的智能化解决方案。充分发挥通用 Agent 能力,业务逻辑在 Skill 中快速迭代。

一、背景与技术方案

安全审计的核心挑战

随着平台 API 规模持续扩张,安全审计面临新的规模化挑战。

image.png

主要挑战:

  • 覆盖面不足:抽样审计约覆盖 ~20%
  • 时效性压力:新接口需要更快的安全评估
  • 规则一致性:标准化检测规则难以沉淀

技术选型与建设契机

当前,以大型语言模型为基座的 AI Agent 在代码语义理解、逻辑推理与自动化执行等维度的能力已超预期成熟,工程落地准确率与稳定性得到大规模验证。这一技术跃迁使全量自动化安全审计从概念验证走向可靠实践。

传统人工抽样模式在数万条 API、数百个微服务的规模下已难以为继,而基于 AI Agent 的方案可实现 100% 路由覆盖与分钟级检测响应。本文将围绕这一契机,阐述如何构建贯穿全链路调用链的智能审计体系,解决覆盖面、时效性与规则一致性三大核心挑战。

二、技术架构

整体架构设计

架构说明:常规代码负责任务调度与结果存储,所有 AI 分析工作由超级 Agent 完成。具体项目分析告警时采用 AI 批量筛查 + 人工深度验证的人机协同模式。

image.png

具体项目分析告警时采用 AI 批量筛查 + 人工深度验证的人际协同模式:

image.png

场景化应用:

image.png

架构设计原则:通用 Agent + 业务 Skill 分离

设计优势:

image.png

  • 通用 Agent 能力最大化:充分利用 Claude Code/OpenCode 的代码理解、推理分析、上下文管理、会话恢复等标准能力,不重复造轮子
  • 业务逻辑快速迭代:检测规则、分析流程、报告格式等业务逻辑全部沉淀在 Skill 中,可随时调整优化
  • 任务可追溯可复现:通过 --resume 恢复会话现场,任何分析过程都可回溯、可验证

Skill 层核心组成

gateway-route-vuln-analyzer/
├── SKILL.md # 核心:漏洞分析主流程 
│ ├── 检测决策树(Step 1-4)
│ ├── 危害评估规则
│ └── 报告输出模板 
├── references/ 
│ ├── unauthorized_patterns.md # 越权漏洞模式库
│ ├── logic_flaws.md # 逻辑漏洞检测指南
│ ├── data_classification.md # 数据敏感性分级
│ └── report_template.md # 标准化报告模板
└── scripts/ 
└── mcpcli-gateway # CLI入口(Token优化)

MCP 工具集设计

image.png

三、漏洞检测方法论(以越权为例)

越权漏洞精细化分类

基于公开漏洞案例库分析,细分越权漏洞类型:

image.png

检测决策流程

检测分四步,前两步设有短路退出以降低成本:

路由配置检查:检查 auth_config.publicrequired_scopes,配置无异常则跳过后续审计。登录态识别:遍历调用链查找认证节点,标准认证路由直接信任,跳过代码审计 代码审计(三维检测)。:检查权限注解(@PreAuthorize)、用户 ID 来源(登录态 vs 请求参数)、所有权校验(DB 过滤 vs 代码显式校验)。精细化危害评估:区分越权读取(数据敏感性)和越权操作(利益流向),输出风险等级与修复建议。

精细化危害评估机制

越权读取 - 数据敏感性评估

依据《数据安全法》确立的数据分类分级保护制度及《网络安全法》关于网络运营者数据安全管理义务的相关要求,通过 AI Agent 对源代码文本分析(基于变量名、字段类型、接口定义等代码特征进行技术推断),对路由功能返回涉及的数据资产进行分级评估。

image.png

越权操作 - 利益流向评估

image.png

四、技术优化:Token 成本降低 95%+

问题诊断

通过对多个会话日志的深入分析,识别出 Token 消耗的关键问题:

image.png

Token 消耗热点:

image.png

优化策略与效果

MCP → CLI 转换(mcp2cli)⭐核心优化

原理:将 MCP 工具暴露为 CLI 命令,避免每次会话加载 MCP 上下文。

# 优化前:MCP调用(需加载完整MCP上下文)
Claude → MCP Server → 工具执行
# 优化后:CLI调用(直接执行,无额外上下文)
Claude → Bash → mcp2cli → 工具执行

效果:节省 61% Token 消耗。

工具返回值优化 ⭐关键优化

问题:无参数调用返回完整文件(最大 1.47MB ≈ 500K tokens)。解决方案:为 gitlab_file 添加精准参数,实现按需提取。

image.png

效果:v2 vs v1 再节省 88%。

Early-Exit 模式

原理:标准认证路由直接信任,跳过冗余代码审计。

image.png

效果:标准认证场景节省 50-70%。

AI 友好返回格式 YAML

原理:YAML 天然比 JSON 的 Token 量更少,对 AI 阅读更友好,降低模型解析成本。

五、模型选型原则与决策框架

在路由安全审计场景下,模型选型需围绕准确率与召回率两大核心指标建立决策矩阵:

image.png

最终选型:满足 P0/P1/P2 三重约束的最优模型。选型理由:在召回率 100% 的候选模型中,qwen3.5-plus 以更低的单位成本实现可接受的准确率,是批量场景下的最优解。

局限性说明:

测试集局限性:上述结论基于独立手工标注测试集(100+ 样本),与生产告警数据相互隔离,仅可作为基线参考依据。模型迭代风险:大模型迭代更新速度极快,市场中或存在性能更优、成本更低的全新模型,暂未纳入本次评测范围

AI 不是替代人工,而是放大安全工程师的能力:AI 处理重复性筛查,人工聚焦深度分析和复杂判断。

六、方法论沉淀

定制化漏洞分析能力沉淀:针对 API 越权漏洞场景,构建专属漏洞分析技能体系,持续沉淀检测规则与标准化分析流程,保障规则具备落地实战有效性。精细化危害评估体系:严格区分越权读取与越权操作两类风险行为,引入利益流向分析维度,规避一刀切式风险评级,评估更贴合业务实际风险。Token 成本系统化优化:通过 MCP→CLI 格式转换 + 代码精准按需提取 + Early-Exit 提前终止三层优化方案,整体实现 Token 成本降幅 95%+。场景化分层模型选型:批量例行场景采用高性价比模型,核心关键场景启用高精度模型,在检测效果与使用成本之间实现最优平衡

七、误报分析与改进方向

误报根因分析

基于已完成的复核告警深度分析,识别出以下主要误报原因及改进方案:

image.png

针对性改进方案

强化信任边界追踪(解决 35% 误报)

问题:AI 发现中间层没校验就停止,直接认为存在漏洞,未追踪到最终数据操作层。方案:在 Skill 中增加指导规则:发现校验就停止,发现缺失就继续,必须追踪到信任边界,中间层不能下结论。

上下游参数一致性校验(解决 25% 误报)

问题:下游方法参数有越权可能,但上游调用时未传入资源 ID。方案:分析 Controller 层 Request 对象,确认是否包含资源 ID 字段。如果上游 Request 中无资源 ID 字段,判定为"仅操作当前用户数据"。

Dubbo 配置信息补充(解决 10% 误报)

问题:内部网关转 Dubbo 传参信息 AI 无法获取,导致认证判断错误。方案:引入 Open 网关 Dubbo 配置信息(通过内部管理平台获取),新增 MCP 工具 get_dubbo_config,获取路由的 Dubbo 调用配置。

# 新增工具
"get_dubbo_config": {
    "description": "获取路由的Dubbo调用配置和参数映射",
    "inputSchema": {"route_path": "string", "service_name": "string"}
}

响应体价值预判优化(解决 10% 误报)

问题:AI 将无敏感信息的读操作误判为越权。方案:细化响应体敏感性判断规则。不敏感(可忽略):

纯状态码返回
通用配置信息(活动名称、时间、状态)
流程节点信息

后续规划

image.png

八、小结

网关路由 AI 驱动审计是一个面向 API 安全场景的智能化交付范式,将规模化安全审计升级为 AI 自动化检测 + 精细化危害评估 + Token 成本优化的标准化机制。它回答的问题:如何在网关路由规模快速扩张的背景下,实现安全漏洞的全量自动化检测,同时将成本控制在可接受范围。它证明的事:多个高危外网漏洞的实际发现,验证了 AI 在代码审计场景的有效性。单条成本 ¥0.23,某大型业务集群全量扫描一轮不到 1 万元,成本完全可控。人机协同模式有效:AI 批量筛查 + 人工深度验证,既保证效率又确保准确性。它沉淀的方法:MCP→CLI 转换、精准代码提取、Early-Exit 优化,可复用于其他 AI 安全审计场景。

九、附录

标准化告警报告模板

本项目输出标准化的漏洞分析报告,以下是模板结构:

## 漏洞分析报告

### 1. 路由信息
- 路由ID: [必填:从 analyze_route 返回的 route_id]
- 路径: [必填:分析的路由路径]
- 目标服务: [必填:目标服务名称]
- 描述: [必填:路由描述,如无则填"无"]

### 2. 接口功能分析
- 接口用途: [必填:简要说明接口用途]
- 业务场景: [必填:业务场景描述]
- 调用方: [必填:H5前端/APP客户端/小程序/其他服务]
- 数据敏感性评估:
  - 涉及数据类型: [必填:PII/金融数据/订单信息/配置信息等]
  - 敏感等级: [必填:critical/high/medium/low]
  - 影响用户范围: [必填:全平台用户/特定用户群/内部用户等]

### 3. 调用链分析
[必填:调用链树形展示,使用缩进表示层级关系,标记漏洞点]

示例格式:
[网关入口] (总耗时)
  └─ [认证服务]: [认证接口] (耗时) [认证节点]
  └─ [业务服务A]: [业务接口] (耗时) [漏洞点]
     └─ [业务服务B]: [数据库查询] (耗时)

关键中间件操作(只写与漏洞分析直接相关的部分):
- SQL: [可选:列出执行的 SQL 操作类型]
- Redis: [可选:如有 Redis 操作则填写]
- MQ: [可选:如有 MQ 操作则填写]

### 4. 漏洞详情

⚠️ 说明:
- 如果发现漏洞,必须填写此章节
- 如果未发现漏洞,填写"未发现漏洞"并跳过后续小节

#### 漏洞 1: [必填:漏洞标题]
- 严重等级: [必填:critical/high/medium/low]
- 类型: [必填:越权读取/越权操作/逻辑漏洞/竞争条件]
- 漏洞位置:
  - 服务: [必填:漏洞所在服务名称]
  - 方法: [必填:漏洞所在方法名]
  - 文件: [必填:文件路径:行号]
  - 仓库链接: [必填:GitLab 代码链接]
  - Trace ID: [必填:对应的 trace_id]
- 问题描述: [必填:详细描述漏洞原因和利用机制]
- 调用链位置: [必填:标注漏洞在调用链中的位置]
- 问题代码: [必填:漏洞代码片段,包含行号]
- 漏洞危害:
  1. [必填:直接危害]
  2. [必填:间接危害]
  3. 潜在连锁攻击: [必填:可能的连锁攻击场景]
  4. 合规风险: [必填:法律合规风险]
- 修复建议:
  1. [必填:具体修复方案]
  2. [必填:具体修复方案]
  3. [可选:额外加固建议]

### 5. 分析置信度
- 置信度: [必填:高/中/低]
- 依据: [必填:说明置信度的判断依据]

### 6. 局限性
⚠️ 数据获取不全说明:

| 限制类型 | 说明 | 影响范围 | 建议操作 |
|----------|------|----------|----------|
| [限制类型] | [说明] | [影响范围] | [建议操作] |

### 7. 二方包依赖
- 涉及的二方包: [必填:group_id:artifact_id:version - 用途说明,如无则填"无"]
- 源码获取: [必填:已获取/未获取/不需要]

### 8. 涉及的微服务
⚠️ 重要:必须包含 commit_id 和 project_path

- [service_name1] (commit: [commit_id], project: [project_path])
- [service_name2] (commit: [commit_id], project: [project_path])

往期回顾

  1. AI 驱动:从运营行为到自动化用例的智能化实践|得物技术
  2. 生成式召回在得物的落地技术分享与思考
  3. 立正请站好:一个组件复用 Skill 的工程化实践|得物技术
  4. 财务数仓 Claude AI Coding 应用实战|得物技术
  5. 日志诊断 Skill:用 AI + MCP 一键解决 BUG|得物技术

文 / 炁源

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

AI驱动:从运营行为到自动化用例的智能化实践|得物技术

作者 得物技术
2026年4月23日 10:53

一、项目背景

随着交易业务的快速增长,对质量保障工作提出了更高标准与全新要求。为提升研发体验和架构升级,大量后台页面经历从 Vue -> React -> 全栈的迁移过程。业务演进过程中,后台能力持续迭代与优化;团队在交付新能力的同时,同步保障存量链路的稳定与可预期行为。为进一步完善测试用例覆盖范围,高效支撑回归测试与重构验证工作,需通过技术手段升级质量保障模式,为业务与架构迭代提供更充分的质量支撑。

E2E 测试: 即端到端测试,是一种从用户视角出发,模拟真实操作验证应用完整业务流程的自动化测试方法。自动化生成用例: 用线上内部运营操作日志自动生成 E2E 测试用例,快速覆盖核心流程,解决用例缺失问题。智能元素定位: 自动识别重构等场景 UI 变化并调整定位策略,实现维护流程的自动化。平台化管理: 通过数据看板管理用例和执行结果,让 E2E 测试可追踪、可优化,提升测试效率。

基于以上分析,我们致力于构建一套自动化 + 智能化的 E2E 测试方案,该方案旨在支撑快速迭代开发模式,有效应对用例覆盖与重构验证等场景,从而在现有资源条件下,持续为业务快速迭代和技术架构升级提供可靠的质量保障。

二、价值收益

提供基于线上真实运营行为的自动化 E2E 测试能力,能够实际发现页面线上/重构等场景的体验问题。在页面重构等迭代任务中,通过优化回归测试流程与资源分配,有效进一步提升测试支持效率。测试的页面代码覆盖率≥X%;无代码覆盖率场景步骤执行成功率≥X%。

页面代码覆盖率:页面用例在测试过程中 运行的代码行数 / 该页面关联的所有代码行数 * 100%步骤执行成功率:页面用例在测试过程中 执行成功的步骤数 / 用例总步骤数 * 100%。

三、方案选型

传统 E2E(DOM)

名词解释 核心差异
传统 E2E 基于 DOM 的测试方案,主要通过操作和验证浏览器中的页面元素,来模拟用户行为的测试方案。 定位方式:依赖 XPath/CSS 选择器。用例生成:手动编写或录制。维护成本:(DOM 变化需手动更新用例)。

AI E2E

名词解释 核心差异
AI E2E 指利用人工智能技术,增强传统 E2E 测试的智能化、自适应性和效率的测试方案。 定位方式:视觉识别、语义化分析(如按钮文本、图标)。用例生成:自动生成(基于用户行为日志或需求描述)。维护成本:(AI 自动适应 UI 变化)。

方案对比

传统 E2E 优势: 贴近真实用户操作:实际触发页面渲染和事件。跨页面流程验证:适合测试多步骤业务场景(如登录→下单)。传统 E2E 劣势: 脆弱性:DOM 结构变化易导致用例失败(需频繁维护定位表达式)。执行速度慢:依赖浏览器渲染和网络请求。

AI E2E 优势: 降低维护成本:减少因 UI 微调导致的用例失败。提升覆盖率:AI 可探索非预期路径(如异常输入)。AI E2E 劣势: 黑盒性:AI 决策过程不透明,调试困难。高成本:需积累足够数据优化模型,复杂视觉处理需要更高性能。

基于上述对比分析,我们选用 AI 驱动的 E2E 测试方案,能够在支持重构场景的同时,显著提升用例生成的自动化水平,从而形成高效可持续的自动化测试解决方案。

四、AI 工具选型

特征
Midscene 原生 JavaScript + WebExtensions API;开源 AI 驱动 UI 自动化工具; 支持 Playwright、Puppeteer 定制行为逻辑;支持 Dom 分析和视觉分析,如 Qwen-vl、Chain-vl。
browser-use Python + 大语言模型(LLM)+ 浏览器驱动;基于 Playwright;同时支持视觉和非视觉模型;同时支持 DOM 分析和视觉分析。

Midscene优点: 基于 JS 技术栈,前端友好,开发成本较低;支持 Playwright、Puppeteer 定制行为逻辑;公开渠道反馈与迭代节奏相对清晰;支持 DOM 与视觉多模态分析。如上优点对工程化落地更友好。Midscene缺点: 调用视觉/大模型时存在 Token 消耗与成本。

browser-use优点: 支持 Playwright 定制行为逻辑;支持 DOM 与视觉分析,能力面较全。browser-use缺点: 同样存在 Token 消耗与成本;基于 Python 技术栈,有开发语言熟悉成本。

综合评估后采用 Midscene,主要考量:与现有技术栈匹配: 以 JavaScript 为主,便于与前端工程、现有工具链协同,降低接入与维护成本。能力与扩展方式符合需求: 支持 DOM 与视觉等多路径信息输入,并可在需要时用 Puppeteer / Playwright 补充确定性、可编排的自动化逻辑,覆盖模型不稳定或不适用的环节。工程化与定制空间: 定制与扩展路径清晰,便于按业务拆解控制流、做兜底与回归,贴合当前团队的交付方式。

五、流程设计

核心流程图:

图片

Midscene 测试过程详情:

图片

核心流程可以概括为:将运营真实操作记录转化为可执行的测试用例,并通过智能化的方式在测试环境中回放验证。具体实现链路分为四个阶段:

图片

阶段一:智能用例生成——从“行为记录”到“可执行脚本”

这是整个流程的源头与起点,目标是实现测试用例的“自动化生产”。

image.png

阶段二:灵活执行触发——从“静态资产”到“动态任务”

本阶段负责调度,将静态的测试用例转化为具体的执行任务。

image.png

阶段三:AI驱动执行——从“指令”到“结果”的转化

img_v3_0210v_543748eb-43ae-4d1d-a3e6-8781967b08dg.png

阶段四:平台化数据运营——从“结果”到“决策”的质量闭环

img_v3_0210v_62103562-ae83-42a0-9e55-33fa6e26a09g.png

通过这四个阶段的闭环,系统实现了从真实运营行为到自动化测试用例的完整转化,并将执行结果转化为可运营的质量数据。

六、技术亮点

基于真实环境中的运营行为生成测试用例

image.png

基于 Midscene+Qwen2.5-VL-72B 执行用例

image.png

AI 驱动的精准 UI 交互测试

image.png

代码覆盖率作为硬指标

image.png

七、平台化效果

我们在页面上实现了以下可运营能力,将 E2E 测试从黑盒脚本转变为可管理、可追踪、可复盘的平台系统。

整体数据看板

页面列表(入口):

图片

展示内容:按业务域 / 系统 / 负责人筛选、页面级 PV/UV、用例数量、步骤执行成功率、代码覆盖率等指标。系统能力:页面维度汇总视图,将访问热度(PV/UV)与自动化质量指标(成功率 / 覆盖率)结合,指导用例优先级(高 PV 页面优先铺用例、优先治理)。

用例详情视图

用例列表(抽屉):

图片

展示内容:每条用例绑定监控系统信息、点击行为数量、执行结果、步骤成功率、打标及备注、执行/删除操作。系统能力:以线上运营行为列表为用例数据,将 可执行性评估/识别执行结果与预期偏差/可信度(打标)统一实现。

执行详情(弹窗):

图片

展示内容:按步骤展示 CLICK 结果、详细错误堆栈/描述、页面截图缩略图、单步覆盖率、页面地址与 监控系统链接。系统能力:将每一步的线上行为与 E2E 执行对照落库,让失败从黑盒变为可复盘的证据链。

点击明细(单页面的用例序列):

图片

展示内容:点击序号、点击描述(如点击某某预览弹窗/眼睛按钮)、点击行为数量等。系统能力:系统会为 Click Scope 生成更可读的描述,便于非开发同学理解与复盘,也是后续策略库/稳定性治理的输入。

八、总结展望

面对交易业务快速增长、技术栈频繁迁移、测试资源紧张的多重挑战,我们成功实现了基于真实运营行为的 AI 驱动E2E 测试方案。通过 Midscene 的视觉 AI 能力结合 Puppeteer 的精准控制,实现了用例自动化生成、智能交互执行和质量数据闭环。

之后我们将持续优化 AI 模型的准确率和稳定性,逐步实现从「用例执行」到「质量预测」的升级。同时推动平台向标准化、智能化方向发展,让质量保障真正成为业务创新的加速器而非瓶颈。

往期回顾

1.生成式召回在得物的落地技术分享与思考

2.立正请站好:一个组件复用 Skill 的工程化实践|得物技术 

3.财务数仓 Claude AI Coding 应用实战|得物技术

4.日志诊断 Skill:用 AI + MCP 一键解决BUG|得物技术

5.Redis 自动化运维最佳实践|得物技术

文 /卓翎

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

连载10-Sub-agents 深度解析:从源码理解 Claude Code 的分身术

2026年5月23日 19:33

cover_09_subagents.png

Sub-agents 深度解析:从源码理解 Claude Code 的分身术

AI Coding 系列第 09 篇 · 多 Agent 编排


这篇文章讲到最后只有一句话:Sub-agent 不是一个人,是一套机械规则。 你在后面三节遇到的每个"为什么它会这样做",答案都不在 prompt 工程里——在 runAgent.ts 的几行 if 里。带着这句话往下读,这篇 9000 字的源码分析会变成它的验证过程。


你已经会用 Claude Code 完成单轮任务,也了解 Skill 和 Hook 如何定义知识和自动化规则。但当你面对一个需要"同时做三件事"的复杂项目——比如一边重构后端接口、一边更新文档、一边跑测试——你本能地想让 Claude"分身"去并行处理。

这篇文章从源码层面拆解 Sub-agents 的运行机制。不是教你"可以并行"这种显而易见的话,而是帮你理解:Claude Code 内部是怎么 fork 一个 Agent 的,Agent 之间的上下文隔离到底意味着什么,以及 worktree 隔离、权限继承、生命周期管理这些你在官方文档里找不到细节的东西。


一、先建立正确的心智模型

很多人把 Sub-agent 理解成"多线程"。这个类比有误导性。

更准确的说法是:Sub-agent 是一个独立的 LLM 会话,拥有自己的消息历史、工具集、中止控制器和文件状态缓存,但与父 Agent 共享同一个 AppState 状态树。

看一眼 runAgent.ts 中创建子 Agent 上下文的核心代码:

📂 展开源码:createSubagentContext 调用
// src/tools/AgentTool/runAgent.ts
const agentToolUseContext = createSubagentContext(toolUseContext, {
  options: agentOptions,
  agentId,
  agentType: agentDefinition.agentType,
  messages: initialMessages,
  readFileState: agentReadFileState,
  abortController: agentAbortController,
  getAppState: agentGetAppState,
  shareSetAppState: !isAsync,  // 同步 Agent 与父共享状态写入
  shareSetResponseLength: true,
})

关键细节:

  • readFileState 是从父 Agent 克隆的,不是共享引用。子 Agent 读文件时走自己的缓存,不会污染父 Agent 的文件视图。
  • abortController 对异步 Agent 是全新的(new AbortController()),对同步 Agent 是共享父 Agent 的。这意味着你 Ctrl+C 取消父 Agent 时,同步子 Agent 也会被取消,但后台运行的异步 Agent 不会。
  • shareSetAppState: !isAsync 这行很关键——同步 Agent 能直接写入父 Agent 的状态树,异步 Agent 则完全隔离。

这不是多线程,更像是 Unix 的 fork():创建时复制父进程的内存快照,之后各走各路。

理解这个模型之后,你就能回答一个更根本的问题——为什么必须用 Sub-agent 而不是在主对话里多写几个 prompt?

答案不在"聪明"或"更强",而在结构。主对话的上下文是线性追加的,500 行测试日志、200 行 grep 结果、一堆中间推理——这些信息对"当下执行"是必要的,但对"后续决策"是噪声。Claude Code 不会自动区分临时执行数据和长期决策记忆,默认全当作重要信息存着。

而 Sub-agent 是 Claude Code 里唯一一个结构上允许"执行完即丢弃"的东西。它的上下文窗口独立——噪声进去,结论出来,窗口销毁。主对话永远看不到中间过程。不是优化,是架构层面的隔离。

09_subagent_fork_model.png


二、何时用 Sub-agent:四个问题搞定决策

你不需要读完剩余 700 行源码分析再做决定。问自己四个问题就够了。

09_decision_matrix.png

问题一:主对话真的需要这些中间过程吗?

如果任务的输出超过 50 行,而你只关心里面不到 10 行的内容——用子代理。

  • 跑测试:300 行日志 → 你只需要"通过/失败,哪个挂了" → 信噪比 1%
  • 代码搜索:grep 返回 50 个匹配 → 你需要 3 个关键文件 → 信噪比 6%
  • 日志分析:1000 行错误 → 你需要 1 条根因判断 → 信噪比 0.5%

噪声留在主对话的不是"看着乱",是 token 膨胀。一次 npm test 输出 300 行 ≈ 4500 tokens——后续每轮对话都要重新发送这些噪声。子代理把这些吞下去,吐回 200 tokens 的精炼摘要。从 8800 tokens 压到 3700 tokens,主对话瘦身 58%。

记一条规则:如果你想让主对话记住什么,就别让不重要的东西进入它的上下文。 这就是 Sub-agent 唯一不可替代的价值——结构上允许"执行完即丢弃"。

问题二:子 Agent 需要继承父 Agent 的上下文吗?

  • 需要 → 用 Fork。省略 subagent_type,子 Agent 继承父 Agent 的对话历史和系统提示。共享 prompt cache,便宜。适合"帮我分担当前任务的一部分"。
  • 不需要 → 用 Named Agent。指定 subagent_type,子 Agent 从零开始,只看你写在 prompt 里的信息。适合"帮我做一件独立的事"。

Fork 和 Named Agent 不是高级/低级的区别,是两种通信模式。Fork 是"你继续做这个,我分个身帮你分担";Named Agent 是"你去把这件事做了,我不管你之前干了什么"。

问题三:子 Agent 的修改会污染我当前的编辑工作吗?

  • → 加 isolation: "worktree"。子 Agent 在独立的 git worktree 里工作,修改不碰你的文件。完成后无变更则自动清理,有变更则保留分支让你 review 后合并。
  • 不会(只读/搜索/分析等不写代码的任务)→ 不需要。

Worktree 的附加代价:每个 worktree 借用一个 git branch;node_modules 等大目录通过 symlink 共享(但如果子 Agent npm install 了新包,注意不要污染主仓库的依赖)。详见第五节。

问题四:这笔账划得来吗?

每个 Sub-agent 启动有 1-3 秒开销:克隆文件缓存、构建系统提示、加载 Skills、连接 MCP。同时,它的上下文隔离帮你省下几千到几万 tokens 的噪声搬运。

结论不是"Sub-agent 很贵"也不是"很值",而是——值不值取决于任务信噪比。低信噪比任务(跑测试、搜代码、分析日志)用 Sub-agent 绝对划算;高信噪比任务(直接的对话互动)不需要画蛇添足。

经验法则:

子任务预计耗时 决策
> 3 分钟 启动成本忽略不计,大胆用
30 秒 ~ 3 分钟 信噪比判断决定
< 30 秒 不值得,主对话直接做完

实战:怎么在对话中调用子 Agent

讲的都是"什么时候用",现在说"怎么用"。Claude Code CLI 里只能输入自然语言。 文章中出现的 Agent({subagent_type: "...", ...}) 是 Claude 内部的工具调用格式,不是让读者直接在终端敲的——Claude 读自然语言,帮读者生成这些调用。

你说自然语言 → Claude 解析意图 → Claude 内部生成 Agent() 工具调用 → 子 Agent 干活 → 结果展示给你。

# 触发内置 Explore Agent
帮我找一下项目中所有和 JWT token 验证相关的代码

# 触发你定义的自定义 Agent(如果 .claude/agents/ 里有 code-reviewer.md)
用 code-reviewer 审查 src/auth/ 的安全问题

# 触发 Fork(Claude 判断需要继承上下文时)
重构好了,帮我顺便写一下这三个函数的单元测试

# 流水线(Claude 顺序执行多个 Agent)
用 bug-locator 找到 token 验证失败的原因,然后让 bug-analyzer 分析根因

Claude 内部做的事:匹配你提到的名字(如 code-reviewer)到 .claude/agents/ 或内置 Agent → 生成 Agent() 工具调用(参数是 Claude 自己根据你的描述推断的)→ 子 Agent 启动 → 完成后直接把结果展示给你。你不会看到中间的 Agent({...}) 调用,只看到最终的文字回复。

如果想约束子 Agent 的行为(工具、权限、模型),要么在 .claude/agents/ 里预先配好(推荐),要么在自然语言里说清楚。话说得越具体,Claude 生成的调用参数越精确:

# 粗粒度(Claude 自己判断一切)
帮我审查代码

# 细粒度(你指定 Agent、范围、关注点)
用 code-reviewer 审查 src/auth/tokenValidator.ts,
重点关注硬编码密钥、缺少输入校验、auth 绕过风险,
用 sonnet 模型,最多调 20 轮工具

# 带 worktree 隔离(并行修改不要互相污染)
用 bug-fixer 修复 tokenValidator.ts:42-68 的竞态条件,
用 worktree 隔离,改完跑测试验证

Claude 会把"用 sonnet 模型"翻译成 model: "sonnet","最多调 20 轮工具"翻译成 maxTurns: 20。不是说自然语言万能——有些参数 Claude 可能理解偏差。关键的权限边界和工具白名单建议在 .claude/agents/ 配置里锁死,不要依赖自然语言。

常见疑问:如果同时有 code-review Skill 和 code-reviewer Sub-agent,Claude 选哪个?

源码里没有硬编码优先级。Claude 根据自己的判断二选一——它从系统提示里同时看到"可用 Skill 列表"和"可用 Agent 列表",靠任务特征自行裁量。简单规则性任务(如格式化输出)倾向 Skill;复杂多步骤、需要上下文隔离的任务倾向 Sub-agent。

但有一个非显而易见的耦合:Skill 的 frontmatter 里可以设 context: fork 这种情况下,Skill 的实际执行会被路由到 Sub-agent——Claude 表面在"调用 Skill",底层却启动了一个独立上下文的子 Agent。从这个角度看,Skill 和 Sub-agent 不是互斥选项——context: fork 的 Skill 就是用 Sub-agent 跑的 Skill。

调用后,后台的流程是透明的:

  1. Claude 把自然语言翻译成 Agent() 工具调用 → 源码根据 subagent_type(Claude 判断的)路由到对应 Agent 定义
  2. 创建独立的 LLM 会话——克隆文件缓存、构建系统提示、加载 Skills、连接 MCP
  3. 子 Agent 执行任务,它的工具调用和思考过程不会出现在你的对话里
  4. 完成后,只把最终的文字回复展示在你的对话中

你感知到的:子 Agent 执行期间终端可能显示它的工具调用(如果你开了详细输出),但它返回给你对话的内容只有最终的文字结果。500 行 grep 输出被子 Agent 吞掉了,你只看到"找到了 3 个相关文件,路径如下"。

(本文中的 Agent({...}) 代码示例展示的是 Claude 内部的工具调用格式,方便你理解参数含义。你不是在 CLI 里敲这些代码——这些是 Claude 在你的自然语言指令下生成的。)

下面走进源码,看这些机制具体怎么实现。


三、四种内置 Agent 类型:各有各的活法

09_agent_types_compare.png

Claude Code 不是只有一种 Sub-agent。打开 builtInAgents.ts,你会看到内置 Agent 的注册逻辑:

📂 展开源码:内置 Agent 注册
// src/tools/AgentTool/builtInAgents.ts
const agents: AgentDefinition[] = [
  GENERAL_PURPOSE_AGENT,
  STATUSLINE_SETUP_AGENT,
]

if (areExplorePlanAgentsEnabled()) {
  agents.push(EXPLORE_AGENT, PLAN_AGENT)
}

这段代码展示了本文重点关注的四种 Agent。完整源码中还有 CLAUDE_CODE_GUIDE_AGENT(回答 Claude Code 使用问题)和 VERIFICATION_AGENT(feature gate 控制的验证 Agent),以及 Coordinator Mode 下的动态 Agent 编排分支——它们各有专门的场景,不影响对核心四种的理解。

Explore Agent——只读搜索专家

Explore Agent 的系统提示开头就是一堵墙:

📂 展开源码:Explore Agent 的系统提示(只读限制)
=== CRITICAL: READ-ONLY MODE - NO FILE MODIFICATIONS ===
This is a READ-ONLY exploration task. You are STRICTLY PROHIBITED from:
- Creating new files (no Write, touch, or file creation of any kind)
- Modifying existing files (no Edit operations)
...

它的 disallowedTools 直接禁掉了 AgentFileEditFileWriteNotebookEditExitPlanMode。这不是"建议"只读,而是工具级别的硬限制——Explore Agent 连尝试写文件的机会都没有。

注意这里的设计原则:安全感不是靠 prompt 劝说 Agent "请不要修改文件",而是从工具层把 Write 和 Edit 物理删掉。Agent 没有"自觉性"——唯一可依赖的,是它能调用哪些函数。这不是信任问题,是机械限制。

有两个值得注意的源码细节:

1. 它不带 CLAUDE.md:

📂 展开源码:omitClaudeMd 检查逻辑
// src/tools/AgentTool/runAgent.ts
const shouldOmitClaudeMd =
  agentDefinition.omitClaudeMd &&
  !override?.userContext

Explore Agent 设了 omitClaudeMd: true。原因是 Explore 只做搜索,commit 规范、PR 模板、lint 规则这些 CLAUDE.md 里的指令对它毫无意义。Anthropic 在代码注释里说这个优化"saves ~5-15 Gtok/week across 34M+ Explore spawns"——每周节省约 5-15 Gtok,覆盖 3400 万次以上 Explore 调用,每次省几千 token,累计节约量级惊人。

2. 它也不带 gitStatus:

📂 展开源码:gitStatus 省略逻辑
const { gitStatus: _omittedGitStatus, ...systemContextNoGit } =
  baseSystemContext
const resolvedSystemContext =
  agentDefinition.agentType === 'Explore' ||
  agentDefinition.agentType === 'Plan'
    ? systemContextNoGit
    : baseSystemContext

Explore 和 Plan 不需要会话开始时的 git status 快照(最多 40KB)。如果它们需要 git 信息,会自己跑 git status 获取实时数据,而不是依赖可能已经过期的快照。

Plan Agent——只读架构师

Plan Agent 和 Explore 共享同一套只读限制,但角色定位不同。它的系统提示要求输出结构化的实施方案:

End your response with:
### Critical Files for Implementation
List 3-5 files most critical for implementing this plan

Plan Agent 用 model: 'inherit',继承父 Agent 的模型。Explore 对外部用户默认用 Haiku(追求速度),Plan 则需要和父 Agent 一样强的推理能力。

General-Purpose Agent——全能型选手

tools: ['*'] 意味着它能使用所有工具(写代码、跑测试、执行 bash 命令),是真正的"全能分身"。注意:当 fork 功能开启时,省略 subagent_type 触发的是 Fork 而非 General-Purpose(见下文 Fork 小节)。

📂 展开源码:General-Purpose Agent 定义
export const GENERAL_PURPOSE_AGENT: BuiltInAgentDefinition = {
  agentType: 'general-purpose',
  tools: ['*'],     // 全部工具
  source: 'built-in',
  baseDir: 'built-in',
  // model is intentionally omitted - uses getDefaultSubagentModel()
  getSystemPrompt: getGeneralPurposeSystemPrompt,
}

Fork Agent——上下文继承的分身

Fork 的触发机制不是语义分析——是参数缺失时的默认路由:当 Claude 生成 Agent() 调用但省略了 subagent_type,且 fork feature gate 开启时,自动走 Fork 路径(AgentTool.tsx:322)。它的特征是继承父对话的完整上下文——你不需要在 prompt 里重复"我刚才重构了哪些文件",Fork 子 Agent 从对话历史里自己知道。

和 Named Agent 的区别一目了然:

你:重构 src/auth/ 的 token 验证逻辑,改了三个函数
Claude:完成
你:顺便帮我写一下这三个函数的单元测试吧
   → Claude 判断:子 Agent 需要知道"刚才重构了哪些函数"
   → 触发 Fork:子 Agent 从对话中直接知道,不需要你重复说

你:检查 src/auth/ 有没有安全问题
   → Claude 判断:审查独立于之前的对话
   → 使用 code-reviewer(Named Agent):从零开始,只看你给的信息

Fork 不能嵌套——Fork 子 Agent 不能再创建自己的子 Agent。多层编排的工作由主对话负责。

📂 展开源码:Fork 的定义、触发路由、消息构建和防递归机制
// src/tools/AgentTool/forkSubagent.ts
// Not registered in builtInAgents — used only when !subagent_type
export const FORK_AGENT = {
  agentType: 'fork',
  tools: ['*'],
  maxTurns: 200,
  model: 'inherit',
  permissionMode: 'bubble',
  getSystemPrompt: () => '',  // 继承父 Agent 的系统提示
}

触发路由(AgentTool.tsx:322):

const effectiveType = subagent_type ??
  (isForkSubagentEnabled() ? undefined : 'general-purpose')
// subagent_type 缺失 + fork 门开启 → Fork 路径

Fork 子 Agent 产生字节级相同的 API 请求前缀,共享 prompt cache 所以比新建 Agent 便宜。防递归靠 isInForkChild() 检测对话中的 <fork_boilerplate> 标记直接拒绝。

四种 Agent 的分工很清楚:Explore 找,Plan 想,General-Purpose 做。Fork 省略 subagent_type 即可触发——需要继承上下文时不加字段走 fork,独立任务时指定类型走 Named Agent。不能在 .claude/agents/ 里定义 fork 类型的自定义 Agent。


四、.claude/agents/ 自定义 Agent:你只需要关注四个字段

除了内置 Agent,你可以在 .claude/agents/ 目录下用 Markdown + YAML frontmatter 定义自己的 Agent。

先说不该做的事:把 Zod Schema 里所有 14 个字段背下来。你真正每次都要认真设计的只有四个——description(何时触发)、tools / disallowedTools(权限边界)、model(成本决策)。其余字段有需要时再查。

一个接近实战的配置示例(不是模板,是设计思路的载体):

---
name: code-reviewer
description: "Reviews code changes for quality, security, and consistency with project conventions. Use when you want a second opinion on code before committing."
model: inherit
permissionMode: dontAsk
tools:
  - Read
  - Glob
  - Grep
  - Bash
disallowedTools:
  - Write
  - Edit
  - NotebookEdit
skills:
  - code-review
hooks:
  SubagentStop:
    - hooks:
        - type: command
          command: "echo 'Code review completed at $(date)' >> .claude/review-log.txt"
maxTurns: 30
---

You are a code review specialist. Your job is to analyze code changes and provide actionable feedback.

Focus on:
- Security vulnerabilities (injection, auth bypass, data exposure)
- Performance issues (N+1 queries, unnecessary allocations, blocking calls)
- Error handling gaps (missing try-catch, unhandled promise rejections)
- Consistency with existing patterns in the codebase

Be specific: cite file paths and line numbers. Don't flag style issues unless they affect readability.

四个需要认真设计的字段:

  • description:不是你写给人看的注释——是 Claude 判断"何时自动调用这个 Agent"的唯一依据。写清楚做什么什么时候用。关键词 proactively 会鼓励 Claude 在合适的时机主动委派。
  • tools vs disallowedTools:白名单黑名单二选一,不要同时用。只读审查用 tools: [Read, Grep, Glob];需要大部分工具但排除个别危险的用 disallowedTools: [Write, Edit]。原则:最小权限——能用 Read 完成的就不要给 Edit。
  • model:不是越强越好。代码审查/分析推理 → sonnet;执行固定流程/模式匹配 → haiku;需要和主对话同等推理 → inherit。选错模型比选错工具更贵——Anthropic 的研究表明,升级模型的性能提升往往超过翻倍 token 预算的效果。
  • skills:Agent 不会自动继承主对话的 Skill。如果子 Agent 需要某个 Skill 的知识(如链路的 SLA 约束、历史事故记录),必须在 skills 字段显式列出,Skill 内容会在 Agent 启动时注入为 isMeta: true 的系统消息。

其余字段说明(有需要再看):

字段 用途 何时需要考虑
permissionMode 覆盖权限确认行为 异步 Agent 建议设 dontAsk
maxTurns 限制工具调用轮数 防止跑飞,建议 20-50
background 强制后台运行 长时间任务的非阻塞执行
isolation worktree 隔离 并行修改不同模块时启用
mcpServers Agent 专属 MCP 连接 需要访问特定外部服务
hooks Agent 生命周期的自动动作 SubagentStop 写日志等
initialPrompt 首轮额外注入的提示 给 Agent 额外的任务约束
memory 记忆作用域 跨会话共享知识

Agent 来源优先级

同名 Agent,后加载的覆盖先加载的:

// 覆盖链:内置 → 插件 → 用户级(~/.claude/agents/) → 项目级(.claude/agents/) → 企业管理策略
const agentMap = new Map<string, AgentDefinition>()
for (const agents of [builtIn, plugin, user, project, flag, managed]) {
  for (const agent of agents) {
    agentMap.set(agent.agentType, agent)  // 后写入覆盖先写入
  }
}

这意味着:你在 .claude/agents/ 里定义的 general-purpose Agent 会替换内置的通用 Agent。企业管理员可以通过策略设置强制覆盖所有 Agent 定义。

所以你应该怎么做:配置完 Agent 后,用一个简单任务测试 description 是否能正确触发。如果 Claude 该用的时候不用,大概率是 description 写得像"自我介绍"而不是"使用条件"。


五、Worktree 隔离:给 Agent 一个独立的代码沙箱

当你设置 isolation: "worktree" 时,子 Agent 会在一个独立的 git worktree 中工作。先理解概念:git worktree 让你在同一个仓库里同时 checkout 出多个分支到不同目录——每个目录像一个独立的仓库副本,有各自的 HEAD,但共享同一个 .git 目录。你不必为了在新分支上工作而 stash 当前修改。

09_worktree_architecture.png

本质上就是 fork() + chroot():共享同一个 .git,但每个 Agent 看到的文件系统是独立的隔离视图。

创建流程

📂 展开源码:Worktree 创建流程 (createAgentWorktree)
// src/utils/worktree.ts - createAgentWorktree
export async function createAgentWorktree(slug: string): Promise<{
  worktreePath: string
  worktreeBranch?: string
  headCommit?: string
  gitRoot?: string
}> {
  validateWorktreeSlug(slug)

  // 关键:使用 findCanonicalGitRoot 而不是 findGitRoot
  // 确保 Agent worktree 总是创建在主仓库的 .claude/worktrees/ 下
  // 而不是嵌套在某个会话 worktree 的 .claude/worktrees/ 里
  const gitRoot = findCanonicalGitRoot(getCwd())

  const { worktreePath, worktreeBranch, headCommit, existed } =
    await getOrCreateWorktree(gitRoot, slug)

  if (!existed) {
    await performPostCreationSetup(gitRoot, worktreePath)
  }
  return { worktreePath, worktreeBranch, headCommit, gitRoot }
}

创建后的自动化设置

performPostCreationSetup 做了一系列你手动操作很容易遗漏的事:

  1. 复制 settings.local.json:本地设置可能包含敏感配置,需要传播到 worktree
  2. 配置 git hooks 路径:让 worktree 复用主仓库的 .husky.git/hooks,避免 pre-commit hook 失效
  3. 符号链接大目录:根据 settings.worktree.symlinkDirectories 配置,symlink node_modules 等目录避免磁盘膨胀
  4. 复制 .worktreeinclude 指定的文件:gitignore 的文件(如 .env、build 产物)不在 worktree 中,但可以通过 .worktreeinclude 声明需要哪些

Worktree 的生命周期管理

Agent worktree 有一个优雅的"按需保留"机制:

📂 展开源码:Worktree 变更检查 (hasWorktreeChanges)
// 检查 worktree 是否有变更
export async function hasWorktreeChanges(
  worktreePath: string,
  headCommit: string,
): Promise<boolean> {
  // 检查 1: 有没有未提交的改动
  const status = await execFileNoThrowWithCwd(
    gitExe(), ['status', '--porcelain'], { cwd: worktreePath })
  if (statusOutput.trim().length > 0) return true

  // 检查 2: 有没有新的 commit
  const revList = await execFileNoThrowWithCwd(
    gitExe(), ['rev-list', '--count', `${headCommit}..HEAD`], { cwd: worktreePath })
  if (parseInt(revListOutput.trim(), 10) > 0) return true

  return false
}

如果子 Agent 完成后没有任何变更,worktree 会被自动清理。如果有变更(新 commit 或未提交的修改),worktree 和分支会保留,返回路径和分支名让你后续处理。

还有一个后台清理机制,定期扫描过期的临时 worktree:

📂 展开源码:临时 Worktree 清理模式 (EPHEMERAL_WORKTREE_PATTERNS)
const EPHEMERAL_WORKTREE_PATTERNS = [
  /^agent-a[0-9a-f]{7}$/,           // AgentTool 创建的
  /^wf_[0-9a-f]{8}-[0-9a-f]{3}-\d+$/, // WorkflowTool 创建的
  /^bridge-[A-Za-z0-9_]+(-[A-Za-z0-9_]+)*$/, // Bridge 创建的
]

只有匹配这些模式的 worktree 才会被自动清理——你手动通过 EnterWorktree 创建的 worktree(比如 feature-redesign)永远不会被误删。

Fork + Worktree 的组合

当 Fork 子 Agent 在 worktree 中运行时,会收到一条特殊的上下文通知:

📂 展开源码:Worktree 上下文通知 (buildWorktreeNotice)
// src/tools/AgentTool/forkSubagent.ts
export function buildWorktreeNotice(
  parentCwd: string, worktreeCwd: string,
): string {
  return `You've inherited the conversation context above from a parent
agent working in ${parentCwd}. You are operating in an isolated git
worktree at ${worktreeCwd} — same repository, same relative file
structure, separate working copy. Paths in the inherited context refer
to the parent's working directory; translate them to your worktree root.
Re-read files before editing if the parent may have modified them...`
}

这段提示告诉 Fork 子 Agent:你继承的上下文里的文件路径指向父 Agent 的工作目录,你需要把路径"翻译"到自己的 worktree 里。这是一个容易被忽略但非常关键的细节。

并行修改不同模块时启用 worktree,每个模块独立分支互不干扰。只读探索不需要。如果子 Agent 要在 worktree 里 npm install 新依赖,记得在 .worktreeinclude 里声明 .env 等被 gitignore 的关键文件。


六、权限模型:谁能做什么

Sub-agent 的权限控制是分层的,不是简单的"继承父 Agent 权限"。

权限模式覆盖

📂 展开源码:权限模式覆盖逻辑
// src/tools/AgentTool/runAgent.ts
const agentGetAppState = () => {
  const state = toolUseContext.getAppState()
  let toolPermissionContext = state.toolPermissionContext

  // Agent 定义的权限模式可以覆盖父 Agent 的
  // 但 bypassPermissions 和 acceptEdits 模式永远不会被覆盖
  if (
    agentPermissionMode &&
    state.toolPermissionContext.mode !== 'bypassPermissions' &&
    state.toolPermissionContext.mode !== 'acceptEdits'
  ) {
    toolPermissionContext = {
      ...toolPermissionContext,
      mode: agentPermissionMode,
    }
  }

  // 异步 Agent 不能显示权限弹窗——自动拒绝需要确认的操作
  if (shouldAvoidPrompts) {
    toolPermissionContext = {
      ...toolPermissionContext,
      shouldAvoidPermissionPrompts: true,
    }
  }
}

几条规则:

  • bypassPermissions(SDK 模式)和 acceptEdits 永远优先——子 Agent 不能收窄这两种宽松模式
  • 异步 Agent 设置 shouldAvoidPermissionPrompts: true,遇到需要用户确认的操作会自动拒绝
  • permissionMode: 'bubble' 是 Fork 的默认模式,权限请求会"冒泡"到父 Agent 的终端

工具过滤

📂 展开源码:工具过滤器 (filterToolsForAgent)
// src/tools/AgentTool/agentToolUtils.ts
export function filterToolsForAgent({ tools, isBuiltIn, isAsync }): Tools {
  return tools.filter(tool => {
    if (tool.name.startsWith('mcp__')) return true  // MCP 工具不受限
    if (ALL_AGENT_DISALLOWED_TOOLS.has(tool.name)) return false
    if (!isBuiltIn && CUSTOM_AGENT_DISALLOWED_TOOLS.has(tool.name)) return false
    if (isAsync && !ASYNC_AGENT_ALLOWED_TOOLS.has(tool.name)) return false
    return true
  })
}

三层过滤:

  1. 所有 Agent 禁用的工具ALL_AGENT_DISALLOWED_TOOLS):比如 ExitPlanMode——子 Agent 不应该改变父 Agent 的计划模式
  2. 自定义 Agent 额外禁用的CUSTOM_AGENT_DISALLOWED_TOOLS):用户定义的 Agent 比内置 Agent 受限更多
  3. 异步 Agent 的白名单:后台运行的 Agent 只能使用一个限定的工具子集

MCP 工具(mcp__ 前缀)不受这些限制,始终可用。

allowedTools 的权限隔离

📂 展开源码:allowedTools 权限隔离
// 父 Agent 的 session-level 权限不会泄露到子 Agent
if (allowedTools !== undefined) {
  toolPermissionContext = {
    ...toolPermissionContext,
    alwaysAllowRules: {
      cliArg: state.toolPermissionContext.alwaysAllowRules.cliArg, // 保留 SDK 级权限
      session: [...allowedTools], // 替换为子 Agent 自己的权限
    },
  }
}

注意这里的 cliArgsession 的区分:SDK 通过 --allowedTools 传入的权限(cliArg)是全局的,所有 Agent 都继承;而会话级别的权限(session)在子 Agent 创建时会被重置,防止父 Agent 运行时积累的权限无意间泄露给子 Agent。

自定义 Agent 首选白名单(tools),明确列出允许的工具,而不是依赖 disallowedTools 排除。异步 Agent 必须配合 permissionMode: 'dontAsk'bubble——否则需要确认的操作被静默拒绝,Agent 不知道原因就反复重试,看起来像卡住了。


七、Agent 的完整生命周期与 Hook 联动

Hook 不是独立于生命周期的外挂——SubagentStartSubagentStop 本身就是生命周期的两个关卡。先看 Hook 怎么嵌入,再看完整流程。

Hook 如何在生命周期中触发

在 Hooks 篇里讲过 SubagentStartSubagentStop 事件,这里从 Agent 源码看触发机制。

SubagentStart:启动前的注入

📂 展开源码:SubagentStart Hook 注入
// src/tools/AgentTool/runAgent.ts
// 执行 SubagentStart hooks 并收集额外上下文
const additionalContexts: string[] = []
for await (const hookResult of executeSubagentStartHooks(
  agentId, agentDefinition.agentType, agentAbortController.signal,
)) {
  if (hookResult.additionalContexts?.length > 0) {
    additionalContexts.push(...hookResult.additionalContexts)
  }
}

// 把 Hook 注入的上下文作为用户消息添加到初始对话中
if (additionalContexts.length > 0) {
  const contextMessage = createAttachmentMessage({
    type: 'hook_additional_context',
    content: additionalContexts,
    hookName: 'SubagentStart',
    ...
  })
  initialMessages.push(contextMessage)
}

SubagentStart Hook 可以向子 Agent 注入额外的上下文信息——比如团队编码规范的摘要、当前 Sprint 的约束条件、或者从 CI 系统拉取的最新构建状态。

Agent 自带的 Hooks

Agent 定义的 frontmatter 可以声明自己的 hooks,这些 hooks 会在 Agent 启动时注册为 session hooks,Agent 结束时自动清理:

📂 展开源码:Agent 专属 Hooks 注册/清理
// 注册 Agent frontmatter 中的 hooks
// isAgent=true 会把 Stop hooks 转换为 SubagentStop
if (agentDefinition.hooks && hooksAllowedForThisAgent) {
  registerFrontmatterHooks(
    rootSetAppState,
    agentId,
    agentDefinition.hooks,
    `agent '${agentDefinition.agentType}'`,
    true,  // isAgent - converts Stop to SubagentStop
  )
}

// ... Agent 运行 ...

// 清理
finally {
  if (agentDefinition.hooks) {
    clearSessionHooks(rootSetAppState, agentId)
  }
}

isAgent = true 这个参数把 Agent frontmatter 里声明的 Stop hooks 自动转换成 SubagentStop hooks。因为子 Agent 完成时触发的不是 Stop(那是主会话结束时的事件),而是 SubagentStop

完整流程:从创建到销毁

一个 Sub-agent 从创建到销毁经历的完整流程:

启动阶段:

  1. 生成唯一的 agentIdcreateAgentId()
  2. 解析模型选择(Agent 定义 → 父 Agent 模型 → 默认模型)
  3. 如果启用了 Perfetto tracing,在追踪树中注册
  4. 克隆父 Agent 的 readFileState(文件缓存隔离)
  5. 构建上下文:Explore/Plan 去掉 CLAUDE.md 和 gitStatus
  6. 执行 SubagentStart hooks,收集额外上下文
  7. 注册 frontmatter hooks(Stop → SubagentStop 转换)
  8. 预加载 frontmatter 中声明的 Skills
  9. 初始化 Agent 专属的 MCP Servers
  10. 记录初始消息到 sidechain transcript

运行阶段:

📂 展开源码:生命周期:运行阶段 (query loop)
for await (const message of query({
  messages: initialMessages,
  systemPrompt: agentSystemPrompt,
  canUseTool: hasPermissionsToUseTool,
  toolUseContext: agentToolUseContext,
  querySource,
  maxTurns: maxTurns ?? agentDefinition.maxTurns,
})) {
  // 转发 API metrics 到父 Agent 的显示
  // 记录每条消息到 sidechain transcript
  // 检测 max_turns_reached 信号
  yield message  // 流式输出给父 Agent
}

清理阶段(finally 块):

📂 展开源码:生命周期:清理阶段 (finally cleanup)
finally {
  await mcpCleanup()                          // 清理 Agent 专属 MCP 连接
  clearSessionHooks(rootSetAppState, agentId)  // 清理 session hooks
  cleanupAgentTracking(agentId)               // 清理 prompt cache 追踪
  agentToolUseContext.readFileState.clear()    // 释放文件缓存内存
  initialMessages.length = 0                  // 释放 fork 上下文消息
  unregisterPerfettoAgent(agentId)            // 释放 Perfetto 注册
  clearAgentTranscriptSubdir(agentId)         // 释放 transcript 映射
  // 清理 AppState.todos 中的孤儿条目
  // 杀死 Agent 启动的后台 bash 任务
  // 杀死 Agent 启动的 Monitor 任务
}

每一步清理都有明确的必要性。比如最后两步——如果不杀死 Agent 启动的后台 shell 循环(run_in_background 的任务),这些进程的父进程退出后会被 init 进程(PID=1)接管,变成"僵尸进程",在主会话退出后依然残留运行。


八、异步 Agent vs 同步 Agent:不只是"后台运行"

这不是简单的"加个 run_in_background: true"的区别。两种模式在架构上有本质不同。

fork() 的术语来说:同步 Agent 是 fork() + waitpid()——父进程阻塞等子进程结束;异步 Agent 是 fork() + detach——子进程独立运行,父进程继续干活。

09_sync_vs_async.png

维度 同步 Agent 异步 Agent
AbortController 共享父 Agent 的 独立的新实例
setAppState 共享父 Agent 的 隔离(通过 rootSetAppState 间接写入)
权限弹窗 可以显示 自动拒绝(shouldAvoidPermissionPrompts
工具集 完整(经过过滤) ASYNC_AGENT_ALLOWED_TOOLS 白名单
非交互模式 继承父 Agent 强制 isNonInteractiveSession: true
thinking 禁用 禁用
完成通知 直接返回结果 通过 enqueueAgentNotification

一个容易踩坑的点:异步 Agent 用 bubble 权限模式时,权限请求会冒泡到父 Agent 的终端,看起来像是同步的权限请求,但其实来自一个后台 Agent。这在同时运行多个异步 Agent 时可能造成困惑。

还有一个更隐蔽的坑:Agent 被静默拒绝后,不会告诉你"我卡在权限上了"。它只知道"操作失败了",然后用同样的方式重试。

所以你应该怎么做:短任务用同步(能直接看输出),长任务(>2 分钟)用异步(不阻塞主对话)。异步 Agent 启动前确保配了 permissionMode: 'dontAsk'bubble,并限定工具白名单——否则背景 Agent 会因权限不足静默失败,反复重试你也不知道为什么。


九、实战案例:基于源码理解的正确用法

本节中的 Agent({...}) 示例是 Claude 内部生成的工具调用格式,展示参数含义。CLI 中实际输入的是自然语言——Claude 帮你翻译成这些调用。

下面四个案例从简单到复杂递进:单一 Agent 审查 → 探索+实现串行流水线 → 多 Agent worktree 并行重构 → 影响面分析的事前拦截。

案例 1:并行代码审查

最基础的用法——四个完全独立的只读任务,并行执行。你要审查一个大 PR,涉及四个模块。每个模块的审查完全独立——审查 auth 的结果不影响审查 payment 的判断。

# 你在 CLI 里说:
用 code-reviewer 同时审查 src/auth/、src/payment/、src/order/、src/user/
四个模块的最新改动,每个模块独立审查,汇总成一份安全报告。

Claude 内部会把这一句话拆成四个并行的 Agent({subagent_type: "code-reviewer", ...}) 调用,四个审查 Agent 同时启动,各自只读分析自己负责的模块。

为什么这里必须用 Named Agent 而不是 Fork?因为审查 Agent 不需要知道你之前和 Claude 聊了什么——它只需要知道"去读哪几个文件"。Named Agent 从零开始,干净;Fork 继承你的对话历史,多余。

案例 2:探索 + 实现的流水线

案例 1 是"四个任务互不依赖"的并行模式。但现实中有很多任务是串行依赖的——先探索再实现,后一步需要前一步的输出。

# ❌ 错误:你说"帮我把 auth token 验证改成用 JWT,同时探索一下现在怎么实现的"
# → Claude 可能并行启动搜索和实现 → 实现 Agent 不知道搜索的发现

# ✅ 正确:
# 第一步:先搜索
用 Explore 找到项目中所有和 auth token 验证相关的实现,返回文件路径和函数名。

# 第二步:拿到搜索结果后,基于结果去改
# Claude 返回:tokenValidator.ts:42 用自定义 HMAC,session.ts:18 管理令牌生命周期
基于刚才 Explore 的结果,用 general-purpose Agent 把 tokenValidator.ts:42
的 HMAC 验证改成 JWT,同时更新 session.ts:18 的令牌生命周期逻辑。
# 注意这里 Claude 继承了对话上下文(Fork),知道 Explore 返回了什么

案例 3:Worktree 隔离的并行重构

案例 2 是串行流水线。现在回到并行——但这次每个 Agent 都会改文件,不再是只读。四模块重构可以并行,但需要各自独立的分支,互不污染。

# 你在 CLI 里说:
用四个 Agent 并行重构 user、product、order、payment 模块,
都改成 repository 模式。每个 Agent 用 worktree 隔离,
在自己的 git 分支上改。完成后告诉我各自的分支名。

Claude 内部给四个 Agent 各加 isolation: "worktree"。完成后每个 Agent 的改动在各自的临时分支上——你可以逐个 git diff 审查,不满意的直接删分支。四个重构互不干扰,也不用 stash 你当前的工作。

案例 4:影响面分析——堵住"正确代码、错误后果"的漏洞

前三个案例关注的是"怎么做"。案例 4 关注的是"该不该做"——用 Agent 在代码动工之前完成安全检查。

一个真实线上事故:开发者让 AI 对存量系统做功能迭代。代码本身没 bug,逻辑完全正确。上线后用户端 7 秒拿不到返回结果——新加的数据库查询增加了约 200ms 延迟,压垮了一个只剩 500ms 余量的 SLA 链路。

根因不是代码质量——是设计阶段缺少影响面分析

# 你说:
我准备重构 src/auth/tokenValidator.ts 的令牌验证逻辑。
先用 impact-analyzer 检查这个改动会影响哪些调用链,有没有 SLA 风险。

Claude 启动 impact-analyzer——这个 Agent 通过 skills: ["chain-knowledge"] 预加载了链路拓扑和 SLA 约束,能追踪每一层调用关系。它返回的分析报告会告诉你:这个改动会影响订单服务和支付回调链,SLA 余量只剩 300ms,你的改动可能让端到端延迟超限。

只有当影响面分析通过后,才启动修改 Agent。 这个流程把 Sub-agent 从一个"事后审查"的辅助角色,升级成了"事前拦截"的工程防线——不是代码写好后再检查,而是代码还没写就先堵漏洞。

流水线中的交接契约

案例 2 展示了一条串行流水线——Explore 找 → General-Purpose 改。当流水线拉长到三四个阶段时,上下游之间需要交接契约(Handoff Contract):上游为下游准备的结构化信息,让下游不需要重复任何搜索就能开始自己的分析。

反面教材:Bug Locator 输出"bug 可能在 auth 模块里" → Analyzer 收到后不得不自己又搜了一遍 → 流水线形同虚设。合格交接至少包含:具体文件路径、函数名、行号范围、搜索证据(搜过什么、排除了什么)、为什么怀疑这个位置。

扩展视角:从子代理到 Agent Teams

本文的 Sub-agent 有一个硬约束:子代理只能向主对话汇报,不能互相通信。 打破这个限制的是 Claude Code 的实验性功能 Agent Teams——下篇详解。


十、常见失败模式与源码级诊断

每个失败模式背后都有一个被源码证实了的心理误判。知道"为什么掉坑"比知道"坑在哪"更有用。

失败模式 1:Agent 消耗 token 却不返回有用结果

症状:Agent 运行了很久,做了很多工具调用,最终报告里信息很少——像是做了一大堆工作但没有总结。

心理根因:你以为 Agent 会"自然地"在最后做总结。LLM 没有总结本能——它只是在生成下一个 token。如果最后一轮恰好是工具调用,它不会"觉得"自己需要再补一段文字总结。

源码级原因finalizeAgentTool 优先提取最后一条 assistant 消息中的 text block(agentToolUtils.ts:301-303)。如果为空,会反向遍历所有历史 assistant 消息找第一个有 text 的(agentToolUtils.ts:307-315)——这个 fallback 能兜住一部分情况,但当 fallback 命中的是一条中间过程的思考而不是最终总结时,仍然拿不到有用的结果。根源还是 LLM 本身没有总结本能,以工具调用结束时不会自觉补一段文字。

解决方案:在 Agent 的 prompt 里明确要求"最后一条消息必须是文字总结,不要以工具调用结束"。不是你提示写得不够好——是提取逻辑本身只看最后一条消息。

失败模式 2:异步 Agent 被权限请求卡住

症状:异步 Agent 看起来卡住了,没有错误信息,没有进度,就像"死掉了"。

心理根因:你以为"后台运行 = 自动获得所有权限"。实际上后台运行的真相是"不能弹窗问你 → 自动拒绝 → Agent 不理解为什么被拒 → 重试 → 再次被拒 → 无限循环"。Agent 不会告诉你"我被权限卡住了",因为它的上下文里只有"操作失败了"。

源码级原因:当 shouldAvoidPermissionPrompts 为 true 时,需要权限确认的操作会被自动拒绝。Agent 不理解"拒绝"和"失败"的区别,继续用相同方式重试。

解决方案

  • 给异步 Agent 配置 permissionMode: 'dontAsk' 加上明确的 allowedTools(治本)
  • 或者用 permissionMode: 'bubble' 让权限请求冒泡到你的终端,但多 Agent 并行时一堆弹窗会让你困惑(治标)

失败模式 3:Fork 子 Agent 试图再 fork

症状:Fork 子 Agent 的对话突然终止,没有输出,也没有错误提示。

心理根因:你以为 Fork 就是"一个普通的 Agent,可以再调 Agent"。但 Fork 的本质是"克隆了主对话的上下文,带上防递归标记"。它的设计意图就是"只执行,不分发"——分发的责任在主对话。

源码级原因isInForkChild() 检测到对话中的 <fork_boilerplate> 标记,拒绝了 fork 请求。这不是 bug——所有编排必须由主对话完成,子 Agent 不能嵌套。

解决方案:Fork 子 Agent 收到的 boilerplate 里已经说了"Do NOT spawn sub-agents; execute directly"。如果你的任务确实需要多层 Agent 协作,用 Named Agent 而不是 Fork——让主对话作为唯一的编排者逐阶段调用。

这三个失败模式的共同根因:你把 Agent 当成了,但源码里它是一套机械规则。它不会"觉得该总结了"、"理解权限为什么被拒"、"知道不该再 fork"。每当 Agent 的行为不如预期,第一反应不是改进 prompt,而是去查对应的源码逻辑——通常答案就在几行代码里。


本篇实践任务

任务 1:解剖你项目中的 Agent 调用

在一个中等复杂度的项目上,让 Claude Code 做一个涉及搜索 + 修改的任务(比如"找到所有硬编码的 API URL 并替换为环境变量")。观察它是否主动使用了 Sub-agent,用的是哪种类型,prompt 是怎么写的。对比它的选择和你的直觉。

任务 2:写一个自定义 Agent 配置

.claude/agents/ 下创建一个只读的代码审查 Agent,配置 disallowedToolspermissionModemaxTurns。然后用它审查你最近的一次 commit,观察它的行为是否被配置正确约束了。

任务 3:测试 Worktree 隔离

对一个有测试的项目,启动两个 isolation: "worktree" 的 Agent 并行修改不同模块。完成后检查:各自的 worktree 分支是否独立?git log 是否只包含各自模块的修改?合并时是否有冲突?


下篇预告

第 10 篇:Agent Teams——当子 Agent 开始互相说话

本文讲的 Sub-agent 有一个硬约束:子 Agent 只能向主对话汇报,不能互相通信。而 Claude Code 的实验性功能 Agent Teams 打破了这个限制——Teammates 可以直接发消息、互相挑战结论、共享发现。下一篇讲 Agent Teams 的源码实现和四种核心协作模式:竞争假设、分层评审、模块化开发、规划审批。


AI Coding 系列持续更新。Sub-agent 不是让 Claude 做更多,而是让它记更少——噪声隔离有边界,编排决策有框架。

做 Agent SDK 必须支持的插件能力:8 个钩子搞定横切关注点

作者 lihaozecq
2026年5月18日 10:01

做 Agent SDK 必须支持的插件能力:8 个钩子搞定横切关注点

这是 code-artisan 拆解系列第四篇。

上一篇:Agent 工具系统搭建:4 个内置工具让 Agent 学会写代码

前言

上一篇我们把 4 个 builtin 工具补齐了,Agent 已经能读文件、改文件、跑命令、起后台进程。看起来这套东西已经能干活,但如果我们把它放到"长会话 + 复杂任务"的视角里推演一下,就会出现下面一些问题:

  • 会话每多走一步,messages 数组就多一对 tool_use / tool_result。token 数一路涨,迟早撞到模型 context 上限。主循环自己没有压缩的机制。
  • 假设 LLM 一上来就魔怔,连续 5 次拿同样入参调 read_file,主循环也不会识别,只会乖乖把工具跑五遍,等到 maxSteps 兜底才停。这中间烧的 token 和等待时间都是干浪费。
  • 我们想给所有工具加一层耗时统计、加日志、加额度校验,目前唯一的口子是去每个工具的 invoke 里手动改。一个工具一次还好,14 个 builtin 都改一遍就是体力活。

这些问题有个共同点:它们都不属于"调 LLM → 执行 tool → 再调 LLM"这个核心循环的职责。硬塞进主循环只会让 agent.ts 变成一个又大又长的杂烩文件,加一个能力就要碰一次主循环。这不是我们做完前三篇还想继续往前走的状态。

这一篇我们要把这些横切关注点从主循环里搬出去,落到一个插件系统上。这种东西在后端框架圈里更常被叫做 middleware(Express、Koa、Hono 都用这个词),下面写代码的时候我们直接沿用 middleware 这个名字,跟标题里"插件"指的是同一件事。code-artisan 的真身用了 8 个生命周期钩子(beforeAgentRun / afterModel / beforeToolUse 这些),这篇文章里我们只用最少必要的 4 个就够把机制讲清楚。

顺序还是跟前三篇一样:每个 Part 一份独立可跑的代码,链接挂在节末。


Part 1:middleware 接口 + 主循环织入

我们先把上一篇结尾的主循环捡回来。骨架长这样:

async run({ signal }: { signal?: AbortSignal } = {}) {
  while (!signal?.aborted) {
    const response = await this.provider.invoke({
      messages: this.messages,
      tools: this.tools,
      signal,
    });
    this.messages.push(response);

    const toolUses = response.content.filter(c => c.type === "tool_use");
    if (toolUses.length === 0) return;

    const toolMsgs = await Promise.all(
      toolUses.map(async (call) => {
        try {
          const result = await this.toolImpls[call.name](call.input);
          return { role: "tool", tool_use_id: call.id, content: result };
        } catch (e: any) {
          return { role: "tool", tool_use_id: call.id, content: `Error: ${e.message}` };
        }
      })
    );
    this.messages.push({ role: "tool", content: toolMsgs });
  }
}

这个循环里其实有 4 个时间点天然适合伸出去做事:

  1. 调 LLM 之前invoke 调用前),我们可以在这里改即将传给 LLM 的 messagestools
  2. 调 LLM 之后(拿到 response 之后),我们可以观察输出、改 response、决定要不要继续
  3. 每个工具调用之前(每个 tool_use 真正 invoke 前),我们可以拦截、改 input、做权限/额度校验
  4. 每个工具调用之后(拿到 toolResult 之后),我们可以记日志、加 telemetry、统计耗时

我们就用这 4 个钩子位来定义 middleware 的形态:

interface AgentContext {
  // 共享的 mutable 上下文:middleware 想改 messages / tools / shouldStop 直接改字段
  // 不走 "return 一个 patch 再让框架合并" 的弯路
  messages: Message[];
  tools: FunctionTool[];
  shouldStop?: boolean;
}

interface AgentMiddleware {
  // 4 个钩子全是 async:middleware 完全可能要 await(Part 4 的 auto-compact 会调 LLM)
  beforeModel?: (ctx: AgentContext) => void | Promise<void>;
  afterModel?: (ctx: AgentContext, message: AssistantMessage) => void | Promise<void>;
  beforeToolUse?: (ctx: AgentContext, toolUse: ToolUseContent) => void | Promise<void>;
  afterToolUse?: (ctx: AgentContext, toolUse: ToolUseContent, toolResult: string) => void | Promise<void>;
}

middleware 的写法约定:每个 middleware 都是一个工厂函数返回对象,而不是 class。后面 Part 2 / Part 3 / Part 4 的 middleware 都长这样:一个 xxxMiddleware(options) 函数,里面 return { beforeModel, afterModel, ... }。这么写是因为 middleware 经常要自己持有一些内部状态(hash 滑动窗口、计数器、配置参数),用闭包就够了,比 class 轻一点,也避开 this 绑定的小坑。

接下来我们改主循环。改动其实就一句话:在 4 个钩子位上挨个 await 所有 middleware 的对应方法。

class Agent {
  private middlewares: AgentMiddleware[] = [];

  use(mw: AgentMiddleware) {
    this.middlewares.push(mw);
    return this;
  }

  async run({ signal }: { signal?: AbortSignal } = {}) {
    const ctx: AgentContext = {
      messages: this.messages,
      tools: this.tools,
    };

    // !ctx.shouldStop 是给 middleware 留的干净退出开关:标 true 后主循环跑完当前 step 就退
    while (!signal?.aborted && !ctx.shouldStop) {
      for (const mw of this.middlewares) await mw.beforeModel?.(ctx);

      const response = await this.provider.invoke({
        messages: ctx.messages,
        tools: ctx.tools,
        signal,
      });
      ctx.messages.push(response);

      for (const mw of this.middlewares) await mw.afterModel?.(ctx, response);

      const toolUses = response.content.filter(c => c.type === "tool_use");
      if (toolUses.length === 0) return;

      const toolMsgs = await Promise.all(
        toolUses.map(async (call) => {
          for (const mw of this.middlewares) await mw.beforeToolUse?.(ctx, call);
          try {
            const result = await this.toolImpls[call.name](call.input);
            for (const mw of this.middlewares) await mw.afterToolUse?.(ctx, call, result);
            return { role: "tool", tool_use_id: call.id, content: result };
          } catch (e: any) {
            return { role: "tool", tool_use_id: call.id, content: `Error: ${e.message}` };
          }
        })
      );
      ctx.messages.push({ role: "tool", content: toolMsgs });
    }
  }
}

对比一下上一篇的版本,主循环只多了 4 处 for-await,干的事情还是同一件:调 LLM、执行工具、把结果塞回去。剩下的横切逻辑全部推到 middleware 里去,这正是我们想要的状态。!ctx.shouldStop 这个干净退出开关,Part 2 的 loop-detection 检测到死循环时就会用到。

跑个最简 demo:timing middleware

为了证明这套机制真能跑,我们先写一个最简单的 middleware:统计每次 LLM 调用和每次工具调用的耗时。这个 middleware 本身没什么生产价值,但它能把 4 个钩子全都用到,足够把机制亮相清楚。

function timingMiddleware(): AgentMiddleware {
  const toolTimers = new Map<string, number>();
  let modelStart = 0;

  return {
    beforeModel: () => {
      modelStart = Date.now();
    },
    afterModel: () => {
      console.log(`[timing] model call took ${Date.now() - modelStart}ms`);
    },
    beforeToolUse: (_ctx, call) => {
      toolTimers.set(call.id, Date.now());
    },
    afterToolUse: (_ctx, call) => {
      const elapsed = Date.now() - (toolTimers.get(call.id) ?? 0);
      console.log(`[timing] tool ${call.name} took ${elapsed}ms`);
      toolTimers.delete(call.id);
    },
  };
}

注入 agent 也很轻:

const agent = new Agent({ provider, tools, toolImpls });
agent.use(timingMiddleware());
await agent.run({ signal });

实际跑下来,输出大概长这样:

[timing] model call took 832ms
[timing] tool read_file took 14ms
[timing] model call took 651ms
[timing] tool str_replace took 7ms
[timing] model call took 489ms

我们没动主循环一行,就把"耗时统计"这件横切的事干净地外挂了进去。同样的接口可以再挂日志、挂权限、挂额度校验,思路完全一样。剩下三个 Part 我们就用这套接口,挨个写几个有实际用处的 middleware。

🟢 在线试一下(看 timing middleware 在 ReAct loop 里的实际输出)→ Part 1 Playground

Part 2:loop-detection middleware · 让 Agent 自己识别死循环

机制有了,第一个有实际用处的 middleware 我们写死循环检测

先把场景说清楚:LLM 在一个 ReAct loop 里,理论上每一步都应该往前推进,但实际跑下来你会发现,模型偶尔会卡住。最常见的几种姿势:

  • read_file({ path: "src/utils.ts" }) 拿到一个错(路径不对),它没意识到,下一步又调同一个路径。
  • bash({ command: "npm test" }),命令报错,它分析了一下错误信息,下一步又跑同一个 npm test
  • grep 找一个不存在的符号,找不到,它换了个"差不多但其实没区别"的关键词又找了一遍。

如果不管它,主循环会乖乖把同一个工具陪它跑五遍、十遍、直到 maxSteps 兜底。这中间烧掉的 token 都是干浪费,更糟的是用户得等 30 秒才知道"它没救了"。我们需要一个机制:同一个工具调用模式在最近的步骤里反复出现时,主动叫停

思路:把每次工具调用 hash 一下,扔进一个滑动窗口

我们要回答的问题是"近期是不是反复在调同一个工具 + 同一个入参"。一个直接的做法是把每次 tool_use 的 (name, input) 拼成一个字符串、哈希一下、扔进一个固定大小的滑动窗口。然后看窗口里某个 hash 出现的次数。

为什么用 hash 不用直接比对 input 对象?两个原因:

  1. 比较成本低。窗口里堆着 N 个 input 对象,每次新来一个要跟 N 个比一遍,深比较挺重的。hash 后比的是定长字符串,O(N) 但每次 O(1)。
  2. 键容易复用。hash 是一个稳定的 string,可以直接做 Map 的 key 来计数。

阈值方面我们用两档:

  • warn 阈值(比如 3 次):往 messages 里塞一条 system 提醒,告诉 LLM "你好像在重复,换个思路"。这一档不停 agent,给 LLM 一次自我纠正的机会。
  • hard 阈值(比如 5 次):直接把 ctx.shouldStop = true,主循环跑完当前 step 就干净退出。同时也塞一条 system 消息告诉 LLM 为什么停的,让它最后能给用户一个合理的总结。

完整代码

import { createHash } from "node:crypto";

interface LoopDetectionOptions {
  windowSize?: number;
  warnThreshold?: number;
  hardLimit?: number;
}

const WARN_MESSAGE =
  "SYSTEM: 检测到你似乎在重复调用同一个工具。请确认当前策略是否有效,必要时换个思路。";

const STOP_MESSAGE =
  "SYSTEM: 重复调用模式已触发硬限制。请直接告诉用户当前进展和遇到的障碍,不要再继续尝试。";

function loopDetectionMiddleware(options: LoopDetectionOptions = {}): AgentMiddleware {
  const windowSize = options.windowSize ?? 20;
  const warnThreshold = options.warnThreshold ?? 3;
  const hardLimit = options.hardLimit ?? 5;

  // 状态全在闭包里,每个 .use(loopDetectionMiddleware()) 都拿到独立实例,多 agent 并发互不串台
  const hashes: string[] = [];
  let warned = false;

  return {
    // 钩子用 afterModel:LLM 已经决定调哪些工具,但还没真跑,是阻止资源浪费的最早时机
    afterModel: ({ messages, ...ctx }, message) => {
      const toolUses = message.content.filter(c => c.type === "tool_use");
      if (toolUses.length === 0) return;

      for (const tu of toolUses) {
        const h = createHash("md5")
          .update(`${tu.name}:${JSON.stringify(tu.input)}`)
          .digest("hex")
          .slice(0, 12);
        hashes.push(h);
      }
      if (hashes.length > windowSize) {
        hashes.splice(0, hashes.length - windowSize);
      }

      const counts = new Map<string, number>();
      let max = 0;
      for (const h of hashes) {
        const next = (counts.get(h) ?? 0) + 1;
        counts.set(h, next);
        if (next > max) max = next;
      }

      if (max >= hardLimit) {
        (ctx as any).shouldStop = true;
        // 本轮 LLM 不会看到这条消息(主循环已退);留给下一次 agent.run() 用,见下面解释
        messages.push({ role: "user", content: [{ type: "text", text: STOP_MESSAGE }] });
        console.log(`[loop-detection] hard stop triggered (max repeat = ${max})`);
      } else if (max >= warnThreshold && !warned) {
        warned = true; // 警告只发一次,重复消息只会让 LLM 更乱
        messages.push({ role: "user", content: [{ type: "text", text: WARN_MESSAGE }] });
        console.log(`[loop-detection] warning injected (max repeat = ${max})`);
      }
    },
  };
}

硬停时有一处反直觉的细节:我们已经把 shouldStop = true 了,但代码里还把 STOP_MESSAGE 塞进了 messages,这步看着像多余。

它不多余,是给"下一次"用的。如果调用方在硬停之后继续会话(比如用户看到失败提示后说"再试一次"),下一轮 agent.run() 进来时,LLM 会从 messages 里读到这条 STOP 消息,知道"上一轮是被硬停的,原因是重复调用",从而给出一个合理的总结,而不是接着上一轮的失败模式继续转圈。把这条消息留在 messages 里,本质上是给会话留了一份"上一轮发生了什么"的记账。

触发场景演示

把 loop-detection 挂到 agent 上:

const agent = new Agent({ provider, tools, toolImpls });
agent.use(timingMiddleware());
agent.use(loopDetectionMiddleware({ warnThreshold: 3, hardLimit: 5 }));
await agent.run({ signal });

我们构造一个故意会让 LLM 卡死循环的场景:要求它读一个不存在的文件并分析内容。LLM 会反复调 read_file 同样的路径。输出大致是这样:

[timing] model call took 712ms
[timing] tool read_file took 3ms      // 第 1 次:报错"文件不存在"
[timing] model call took 645ms
[timing] tool read_file took 2ms      // 第 2 次:又调同样的
[timing] model call took 598ms
[timing] tool read_file took 2ms      // 第 3 次
[loop-detection] warning injected (max repeat = 3)
[timing] model call took 723ms        // LLM 看到 warning 了,但没改策略
[timing] tool read_file took 2ms      // 第 4 次
[timing] model call took 654ms
[timing] tool read_file took 2ms      // 第 5 次
[loop-detection] hard stop triggered (max repeat = 5)
[timing] model call took 489ms        // 当前 step 跑完,下一轮被 shouldStop 拦掉

5 次硬停在这个例子里省下大约 20 秒的额外等待,每多一次无意义的模型调用还会多烧几百到几千个 token。一旦 SDK 真的有用户用起来,这种死循环检测就是省时间也省钱的事。

🟢 在线试一下(看 loop-detection 在死循环场景下的拦截过程)→ Part 2 Playground


Part 3:micro-compact middleware · 把旧 tool_result 压成占位符

第二个 middleware 我们来碰前言里提到的第一个问题:长会话 token 一路涨,迟早撞 context 上限。

先看看 token 是怎么堆起来的。一个典型的 coding 会话,假设跑了 20 步,每一步 LLM 都调一两个工具。read_file 的返回随便就是几千字符,bash 跑个 npm test 也能吐一两千字符,grep 一搜结果上百行。这些 tool_result 全都老老实实留在 messages 数组里。下一次调 LLM,我们就把这堆历史完整地喂回去。

问题是:LLM 真的需要看全这些历史 tool_result 吗?

绝大多数时候不需要。回想一下我们自己在 Cursor / Claude Code 里干活的时候:早期那些 read_file 的输出,到了任务后段往往只剩"我之前确实看过这个文件"这一层信息,具体内容已经不重要了,因为关键的内容 LLM 多半已经在思考里"消化"过、或者改进过文件。真正需要逐字保留的是最近几次工具调用的结果。

所以最轻量的压缩思路就出来了:保留最近 N 个 tool_result 原文,把更早的内容替换成一行占位符

一份占位符的设计

我们要替换成什么样的占位符?两个要求:

  1. 保留 tool_use / tool_result 配对结构。Anthropic / OpenAI 协议都要求 tool_use 必须有对应的 tool_result。我们不能直接删掉这条 tool 消息,否则下一次调 LLM 就 400 了。压缩只能改 content 字段,结构不动。
  2. 占位符里要带工具名。让 LLM 一眼看到"哦我之前用 read_file 看过某个东西",而不是看到一个孤零零的 [Previous output omitted] 完全没上下文。

这两条加起来,我们的占位符就长这样:

[Previous tool call output omitted: used read_file]

短、明确、带工具名。

完整代码

interface MicroCompactOptions {
  keepRecent?: number;
}

function microCompactMiddleware(options: MicroCompactOptions = {}): AgentMiddleware {
  const keepRecent = options.keepRecent ?? 10;

  return {
    // 钩子用 beforeModel:哪怕中间没触发任何工具,下次调 LLM 也会重新走一遍压缩判断
    beforeModel: ({ messages }) => {
      const toolNameById = buildToolNameMap(messages);

      // 把所有 tool_result 按出现顺序铺平到一个数组,方便算"哪些要 stub"
      const allResults: ToolResultContent[] = [];
      for (const msg of messages) {
        if (msg.role !== "tool") continue;
        for (const c of msg.content) {
          if (c.type === "tool_result") allResults.push(c);
        }
      }

      if (allResults.length <= keepRecent) return;

      // 倒数 keepRecent 个保持原文,更早的挨个换 content(不改 messages 结构)
      const stubCount = allResults.length - keepRecent;
      for (let i = 0; i < stubCount; i++) {
        const r = allResults[i];
        // 已经 stub 过的不再 stub,避免占位符嵌套或边界情况
        if (r.content.startsWith("[Previous tool call output omitted")) continue;
        const toolName = toolNameById.get(r.tool_use_id) ?? "tool";
        r.content = `[Previous tool call output omitted: used ${toolName}]`;
      }
    },
  };
}

// tool_name 只存在 assistant 消息的 tool_use 块里;tool_result 只有 tool_use_id,先扫一遍建索引
function buildToolNameMap(messages: Message[]): Map<string, string> {
  const map = new Map<string, string>();
  for (const msg of messages) {
    if (msg.role !== "assistant") continue;
    for (const c of msg.content) {
      if (c.type === "tool_use") map.set(c.id, c.name);
    }
  }
  return map;
}

这里有一处设计选择想单独强调:我们是在原对象上直接改 r.content,没有构造新的 messages 数组

这不是为了少写两行代码。主循环和其他 middleware 跑的时候,拿到的都是同一个 messages 引用。如果我们整个换数组,下游引用就指向旧数组了,压缩白做。原地改最稳。这种"middleware 共享 mutable 上下文"的约定是 Part 1 设计 AgentContext 时就定下的基调,到这里得到了实际的兑现。

实际效果

把 micro-compact 挂上:

const agent = new Agent({ provider, tools, toolImpls });
agent.use(timingMiddleware());
agent.use(loopDetectionMiddleware());
agent.use(microCompactMiddleware({ keepRecent: 5 }));
await agent.run({ signal });

跑一个会持续调 10 几次工具的任务(比如让它"读 src 下所有 .ts 文件,统计每个文件的导出数量"),跑到第 8 步时,messages 数组里的早期 tool_result 已经长这样:

{
  role: "tool",
  content: [{
    type: "tool_result",
    tool_use_id: "toolu_01abc...",
    content: "[Previous tool call output omitted: used read_file]"
  }]
}

而最近 5 次工具结果还是原文。整个 messages 体积比不压缩的版本能小一个数量级,具体省多少完全取决于工具返回的大小,但只要是 coding agent,省下来的 token 是肉眼可见的。

micro-compact 的优势是纯本地、零成本:没有调 LLM,没有调外部接口,就是字符串替换。劣势也明显:它只把 tool_result 替换掉了,assistant 消息里的思考和文本块还全保留着。如果会话长到 assistant 消息本身就大几万 token,micro-compact 就顶不住了。这种时候要上 Part 4 的 auto-compact。

🟢 在线试一下(跑一个长任务,观察 messages 里旧 tool_result 被 stub 的过程)→ Part 3 Playground


Part 4:auto-compact middleware · 用 LLM 把整段历史压成摘要

micro-compact 顶不住的场景上一节已经预告了:会话长到 assistant 自己的思考 / 文本块就大几万 token,stub 掉 tool_result 也救不回来。这时候我们要的不是"局部省一点",而是把整段历史压扁

最直接的做法是再请一次 LLM 出场:让它读完整段历史,写一份保留关键信息的摘要,把整个 messages 数组替换成"一条带摘要的 user 消息 + 一条简短的 assistant 应答"。下一次再调 LLM 时,主循环看到的就只有这两条消息(加上后续新加的内容),token 总数瞬间回落到一个安全水位。

这里有两个细节我们先想清楚:

第一,为什么压缩之后是 user + assistant 一对,而不是单独一条 user?因为 Anthropic / OpenAI 协议都要求消息列表里 user / assistant 交替出现。如果我们替换成单条 user 消息,下次真用户又发一条 user,相邻两条 user 协议就会拒。配上一条 assistant 应答("Understood. Continuing with context from the summary.")一切都对得上。

第二,总结这件事本身要不要也走我们的 main provider?理论上可以,但摘要任务远比主对话简单,调一次便宜模型(比如 Claude Haiku)就够,能省下大头费用。所以我们把 summaryModel 留成一个可选参数。

完整代码

interface AutoCompactOptions {
  // 调谁来生成摘要:传一个独立的 LLMProvider,业务里常传便宜模型省钱
  provider: LLMProvider;
  // token 阈值;默认 120k 是为了给"chars/4"这种粗估留余量
  threshold?: number;
  // 自定义 token 计数器;不传就用 chars/4 这种粗估
  countTokens?: (messages: Message[]) => number;
  // 摘要生成后回调,让 backend 等持久化层把摘要写到 DB
  onCompacted?: (replacement: [UserMessage, AssistantMessage]) => void | Promise<void>;
}

const ACK_TEXT = "Understood. Continuing with context from the summary.";

function autoCompactMiddleware(options: AutoCompactOptions): AgentMiddleware {
  const { provider, onCompacted } = options;
  const threshold = options.threshold ?? 120_000;
  const countTokens = options.countTokens ?? defaultCountTokens;

  return {
    // 钩子选 beforeModel:调 LLM 之前先称重,超阈值就先压缩再走主循环
    beforeModel: async ({ messages }) => {
      const estimated = countTokens(messages);
      if (estimated < threshold) return;

      // 把历史摊平成一段纯文本,喂给 summary 模型
      const text = serializeForSummary(messages);
      const response = await provider.invoke({
        messages: [
          { role: "system", content: [{ type: "text", text: "You are a conversation summarizer for coding agent sessions." }] },
          { role: "user", content: [{ type: "text", text: buildCompactPrompt(text) }] },
        ],
      });
      const summary = extractText(response) || "(summary unavailable)";

      const summaryUser: UserMessage = {
        role: "user",
        content: [{ type: "text", text: `[Conversation Summary]\n\n${summary}` }],
      };
      const ackAssistant: AssistantMessage = {
        role: "assistant",
        content: [{ type: "text", text: ACK_TEXT }],
      };

      // 原地清空再 push,保留 messages 数组的引用(Part 3 已经定下的约定)
      messages.length = 0;
      messages.push(summaryUser, ackAssistant);

      if (onCompacted) await onCompacted([summaryUser, ackAssistant]);
    },
  };
}

// 极粗的 token 估算:每 4 个字符算 1 token。对代码和中文都偏低,所以默认阈值留了余量
function defaultCountTokens(messages: Message[]): number {
  return Math.ceil(JSON.stringify(messages).length / 4);
}

serializeForSummary / buildCompactPrompt / extractText 是几个直白的辅助函数(把 messages 转成纯文本、构造摘要 prompt、从返回里抽 text 块),实现没什么巧的地方,篇幅原因这里不展开贴,完整版在 code-artisan 仓库里。

最想单独说的一处是 onCompacted 这个回调。它不是装饰,而是把"压缩这一动作"和"压缩后果如何持久化"显式地解耦。middleware 自己只管做最直接的事:检测、调 LLM、替换 messages。至于摘要要不要存到数据库、要不要发埋点、要不要打个日志,这些 middleware 一概不知道。任何想插手的调用方传一个 onCompacted 回调就行。

这种解耦带来一个直接好处:本地跑实验时我们不传 onCompacted,middleware 照样能跑;接到 backend 上需要持久化时,传一个写 DB 的回调,middleware 代码一行不用动。

实际效果

把三个 middleware 全挂上:

const agent = new Agent({ provider: mainModel, tools, toolImpls });
agent.use(timingMiddleware());
agent.use(loopDetectionMiddleware());
agent.use(microCompactMiddleware({ keepRecent: 5 }));
agent.use(autoCompactMiddleware({
  provider: cheapModel,  // 用便宜模型生成摘要
  threshold: 100_000,    // 估算超过 10 万 token 就触发
}));
await agent.run({ signal });

跑一个跨越几十步的长会话,触发瞬间的输出大致是:

[timing] model call took 645ms
[timing] tool read_file took 14ms
[timing] model call took 712ms
... (跑了 30 多步之后)
[auto-compact] threshold reached (estimated 102348 tokens), summarizing...
[auto-compact] summary length = 1843 chars, messages.length: 672
[timing] model call took 423ms      // 压缩后下一次调主模型,token 数瞬间清爽

messages.length: 67 → 2 就是这套机制最直观的体感。67 条历史压成 2 条,代价是 cheap model 一次摘要调用(几毛钱),换回的是接下来还能继续走几十轮。

四个 middleware 叠在一起,每一个的职责都很窄:timing 管耗时、loop-detection 管死循环、micro-compact 管轻量压缩、auto-compact 管重量级压缩。它们彼此之间不知道对方存在,主循环也没为它们任何一个加过一行特例代码。这才是我们把横切关注点搬出主循环的真正回报。

🟢 在线试一下(看 4 个 middleware 协同工作 + auto-compact 触发瞬间)→ Part 4 Playground


写在最后

到这一篇结束,我们的 Agent 已经从"会用工具的 Coding Agent"升级成了带横切支撑的可生长 SDK

  • Part 1:middleware 接口 + 主循环织入 · 4 个钩子位 + shouldStop 干净退出开关
  • Part 2:loop-detection · MD5 hash 滑窗 + warn / hard 两档阈值
  • Part 3:micro-compact · 旧 tool_result 替换占位符的轻量压缩
  • Part 4:auto-compact · 触发阈值后调 LLM 做摘要,messages 数组重置成 2 条

四个 Part 的代码加起来不算多,但骨架已经具备了继续往后扩的能力。code-artisan 真身的 8 个钩子和上面这 4 个完全是同一套设计思路,只是再多了 beforeAgentRun / afterAgentRun / beforeAgentStep / afterAgentStep 几档时机,可以挂诸如"加载 skills"、"启动 todo 跟踪"这种需要 run 级别 / step 级别钩子才合理的逻辑。后面 06 我们会专门拆一篇 skills 系统。

下一篇我们换个方向:sandbox。前三篇里所有的 tool 我们都直接 node:fs / child_process 跑在 Node 进程里,这在本地实验没问题,但放到任何要让用户 / 外部 Agent 调用的产品里都不能这么干。一条 bash 工具就能让用户读到服务端文件、改服务端配置。第 5 篇会把 builtin 工具背后的执行层抽象成 Sandbox 接口,我们sdk默认给一个 LocalSandbox 实现,再接一份 E2BSandbox 展示工具系统怎么换底而上层零改动。

完整代码在 GitHub:github.com/lhz960904/c…(这篇文章拆的核心文件是 packages/agent/middlewares/packages/agent/types/middleware.ts)。如果觉得这个项目对你有帮助,欢迎点个 star ⭐。

把一份前端 checklist 变成 AI 的 Skill:让 CR 不再靠记忆

作者 jump_jump
2026年5月9日 23:17

引子:一个吃灰三年的项目被重新盘活

写这篇博客的由头有点特别。

我有一个叫 front-end-checklist 的老项目(网页:wsafight.github.io/front-end-c…),2023 年初在公司做 Code Review 的时候顺手整理出来的。那会儿评审新同学的代码,总是在重复同样的话:"这里没做 XSS 转义"、"useEffect 里有竞态"、"label 没和 input 关联"。后来干脆把这些反复出现的问题写成清单,用 Jekyll 挂在 GitHub Pages 上。

然后它就一直在那儿积灰。2024 只有一次提交,2025 只有一次,到了 2026 年 5 月也还没动过。

2026-05-08 晚上,我本来只想顺手改一下清单里过时的条目,结果一头扎进 Claude Code,两小时做了 22 次提交,把这个静态页面彻底改造了一遍。

真正让我想写这篇文章的,不是"AI 让我写代码变快"。快是肯定快,但这次改造最后落到了一件我之前没想过的事情上——一份静态文档,换了个交付形式,才真正开始被用起来。

清单本身:三年沉淀下来的 160 条检查项

先交代清楚这个清单是什么。

它不是 ESLint 能查的那种风格规则,而是 Code Review 时需要结合业务和上下文判断的问题。目前一共 25 个分组、160 条检查项:

命名规范      数据与类型    函数设计      状态管理      控制流
异步处理      数据请求      UI 与渲染     样式与响应式  路由与权限
性能          安全与健壮性  表单与交互    错误处理      测试
无障碍访问    用户体验      代码质量      工程化        国际化
日志与监控    依赖管理      浏览器兼容    文档与协作    PR 自检

随便挑几条看看:

  • 区分"缺失"、"为空"、"为 0"、"为空数组"、"为空字符串"的业务含义
  • 处理并发请求的竞态问题,避免旧响应覆盖新状态
  • 同一表单/输入控件不要在受控与非受控之间切换
  • useEffect / watch 的依赖项必须完整,避免闭包捕获过期值
  • 定时器和事件监听记得清除,否则可能引发内存泄漏
  • 表单错误应定位到具体字段,而不是只给出笼统提示

这些不是靠格式化、类型推导或者简单 AST 规则就能稳定抓出来的问题。它们往往来自真实线上事故、返工、误解和交接成本。以前这些经验只能靠人去记,记不住就会在别的项目里重新踩一次。

痛点:清单挂在网上,但不会进入工作流

清单做完这三年,我一直有个遗憾:没人真的会去翻

我自己都不翻。做一个 PR 的时候,我知道清单里有条"处理并发请求的竞态",但我会不会每次都老老实实打开页面对一遍?不会。同事更不会。新人入职时我会把链接丢给他们,他们收藏,然后就再也不打开了。

这是很多静态文档共同的问题:信息在那里,但你必须主动去触达它。而在 CR 场景里,主动触达的前提是你已经意识到自己可能漏了什么。可真正漏掉的时候,人往往意识不到。

我试过一些办法:

  • 写成 Markdown 放 README:没人会在写代码时切到 README 里逐条对照。
  • 做成可搜索的网页:搜索的前提是你已经知道关键词,可 XSS、竞态、状态错位这类问题,经常就是因为你没意识到该搜什么。
  • 加上勾选和进度追踪:这次改造时我反而把它们删了,因为它把"查阅文档"变成了"填表",使用意愿更低。

根本问题不是文档形式不够花哨,而是查阅式文档很难主动进入人的工作流。

转折:把清单变成 AI 的上下文

Claude Code 的 Skill 机制改变了这件事。

简单说,Skill 是一段给 AI 读的说明书,加上它执行任务时需要参考的资料。当你在对话里说到特定触发词时,AI 会加载这个 Skill,并按说明书走流程。

我的 frontend-checklist Skill 里写的是这样的逻辑:

  1. 只在用户明确请求时触发:比如 /frontend-checklist、"按前端清单 review"、"按 checklist 检查这段代码"。用户没提,它不会自作主张去扫代码。
  2. 确认检查范围:如果用户指定了文件,就只看指定文件;如果说 "review PR",就用 git diff 定位变更;如果都没有,就先确认范围,不盲目扫整个仓库。
  3. 按语言选择清单:中文提问读中文版清单,英文提问读英文版清单。
  4. 只报命中的问题:通过和不适用的条目一律不输出。
  5. 按严重度排序:安全、数据丢失、竞态、内存泄漏这类硬问题优先,命名风格靠后。
  6. 每个问题都标出文件路径和行号:让人能直接跳到具体位置。

关键的翻转在这里:

以前是"你去翻清单"。现在是"清单来找你"。

写 PR 的时候,你不需要记得清单里每一条是什么,直接在 IDE 里说一句"按前端清单 review 我这个 PR",AI 会把 160 条和当前 diff 放在一起看,只告诉你命中的那些,并给出文件路径和行号,必要时附 1-3 行示例代码。心智负担从"记住 160 条"降到"知道有这么一个入口"。

一个真实跑出来的 review

空讲没意思,看一段 showcase 里的实际输出。

下面这段代码是我写的一个有意设计的"坏例子"(showcase/cases/01-xss/bad.tsx),一个评论列表组件:

export function CommentList(props: any) {
  const [list, setList] = useState([] as any);
  const [keyword, setKeyword] = useState('');

  useEffect(() => {
    fetch('/api/comments?topic=' + props.topic)
      .then((r) => r.json())
      .then((d) => {
        setList(d.data);
      });
  }, [props.topic]);

  const highlight = (text, kw) => {
    if (!kw) return text;
    return text.replace(new RegExp(kw, 'g'), '<mark>' + kw + '</mark>');
  };

  return (
    <div>
      <input type="text" onChange={onSearch} />
      <div id="tip" dangerouslySetInnerHTML={{ __html: props.tip }} />
      {list.map((c: Comment, i: number) => (
        <div key={i} className="comment">
          <img src={c.avatar} />
          <a href={'javascript:void(0)'} onClick={() => eval(c.author.onClick)}>
            {c.author.name}
          </a>
          <div dangerouslySetInnerHTML={{ __html: highlight(c.body, keyword) }} />
        </div>
      ))}
    </div>
  );
}

乍看能跑,TypeScript 不一定报错,ESLint 也不一定能拦住关键问题。但 Skill 跑完吐出来 11 条命中,挑几条看(行号对应 showcase/cases/01-xss/bad.tsx 原文件,不是上面代码块里的相对行号):

安全与健壮性(最严重的一批)

  • line 35dangerouslySetInnerHTML={{ __html: props.tip }} 直接吃外部 tip,存在 XSS 风险。建议默认文本渲染,真要富文本时先用 DOMPurify 消毒。
  • line 44highlight 用字符串拼接 HTML 再 dangerouslySetInnerHTMLbodykeyword 都没转义。建议改成 String.split 分段渲染,把命中段包进 <mark>,不要注入 HTML。
  • line 39eval(c.author.onClick) 把接口返回的字符串当代码执行,等于把任意脚本执行权交给接口数据。建议彻底删除,交互改成前端静态映射。

数据请求

  • line 14-20props.topic 变化时发起新请求但没取消旧请求,旧响应可能覆盖新数据。建议用 AbortControllerignore 标志在 cleanup 里关掉。

UI 与渲染

  • line 37key={i} 用数组下标,列表增删重排时可能导致状态错位。建议用稳定的业务 id,比如 c.id

无障碍

  • line 34, 38:搜索 input 没有 aria-label 或关联 label<img> 没有 alt

泛泛地让 AI 做 review,它很容易给出"结构清晰、建议补充错误处理"这类通用意见。扔给 ESLint,也大概率只会在 any、未定义变量、hook 依赖这类规则上发声。Skill 的价值在于把评审标准显式化:AI 不是凭感觉聊几句,而是按一把真实的尺子在量代码。

我还拿其他 showcase 跑了一遍:用户资料页命中 12 条(竞态、未清理副作用、无空值守卫),注册表单命中 17 条(a11y、字段级错误、密码明文 input、防重复提交)。这些都是一眼看过去"差不多能用",但上线后很容易变成坑的代码。

怎么用

Skill 装起来一条命令的事:

curl -fsSL https://github.com/wsafight/front-end-checklist/releases/latest/download/install.sh \
  | sh -s -- claude

装完在对话里说 /frontend-checklist 或"按前端清单 review 我这个 PR",就会触发它。也支持 Kiro / Cursor / Codex,把命令结尾换一下就行。

清单是中英双语的,AI 会根据你提问的语言自动选对应版本。


两小时改造一个老项目听起来像标题党,但实际发生的事情比这更有意思:一份躺了三年没人翻的清单,换了个交付形式,突然就活过来了。内容还是那 160 条,变的只是"它怎么到达读者"。

如果你手里也有这种"明明有价值但没人用"的老文档,值得花个晚上,把它接进 AI 看看。

— 2026-05-09 夜

给 AI Agent 装上"长期记忆":Karpathy 的 LLM Wiki 思想,我做成了工具

2026年5月9日 18:30

你的 AI 每次对话都在重新推导知识。而一个由 Agent 自己维护、会复利增长的 Wiki,让它越用越聪明。

这篇文章不是教你怎么敲 CLI 命令。memex 的入口在 agent 对话里——你只需要说 /memex:capture/memex:ingest/memex:query,Agent 自己知道怎么做。


一、Karpathy 在 2026 年 4 月提出了一个思想

HE9kEdZaMAADLIU.jpg

Andrej Karpathy 是 OpenAI 创始团队成员、前 Tesla AI 总监。2026 年 4 月 4 日,他在 GitHub Gist 上发布了一篇 LLM Wiki Pattern,系统阐述了一个思想:

为什么人类用 Wiki 积累知识,而 AI 每次对话都在从零推导?

他的主张很直接:给 LLM 一个结构化 Markdown Wiki,让它自己维护。人类只负责往 raw/ 里扔源材料,LLM 负责把知识编译进 wiki/——更新概念页、建立交叉引用、标注矛盾、写综合页。每轮对话不是"检索",是"阅读一本已经写好的书"。

他打了个比方,传得很广:

"Obsidian is the IDE, the LLM is the programmer, the wiki is the codebase."

翻译过来就是:"Obsidian 是 IDE,LLM 是程序员,Wiki 是代码库。"

什么意思?你写代码时——IDE 是你的界面,程序员是写代码的人,代码库是持续构建的产物。类比到这里——Obsidian(或任意 Markdown 浏览器)只是你看知识的界面,LLM 才是真正写知识的人,Wiki 就是 LLM 持续构建和维护的知识产物。你不写 Wiki,你看 Wiki;LLM 不读 Wiki,LLM 写 Wiki。

Karpathy 的核心洞见其实用一句话就能说清——他把知识库当代码仓库管理:

软件工程 知识库工程
src/ raw/(原始资料,不可变)
build/ wiki/(编译产物,LLM 自动生成)
编译器 LLM(把 raw 编译成结构化 wiki)
IDE Obsidian / 任意 Markdown 浏览器
Lint / CI 健康检查(断链、矛盾、过期页)
增量编译 每次只 ingest 新增的 raw,不改旧文件

我是开发出身,第一眼看到这张表就懂了。这不就是 CI/CD 的知识库版本吗?

软件工程 → 知识库工程 映射

而 Karpathy 用了一个词来概括这一切——编译(Compile)。把原始资料编译成结构化知识。raw 是源码,wiki 是编译产物。你不会把 .class.java 混在一起,笔记也一样。

核心区别在于:RAG 每次重推,Wiki 持续复利。

这句话拆开看——

RAG LLM Wiki
知识形态 文档切片,无关联 结构化页面,交叉引用
更新方式 重新索引 Agent 直接编辑 Markdown
查询 向量相似度拼凑 读已组织好的页面
累积性 没有复利 每次 ingest 在旧知识上修改、关联
所有权 在厂商的向量库里 在本地 Git 仓库里

Karpathy 给的是思想。我把它做成了工程:memex


二、memex 怎么用?在 agent 对话里说话就行

最重要的概念先摆出来——

你不是在终端敲 memex distillmemex ingest。你是在 agent 对话框里说 /memex:capture/memex:ingest/memex:query。CLI 只在 Agent 脚下跑,你感觉不到它。

memex 提供了 6 个 slash command,覆盖完整的知识生命周期:

Slash Command 你做什么 Agent 做什么
/memex:capture 给 Agent 一个 URL、一段文字、一个文件 Agent 保存到 raw/,记录出处,不变形
/memex:ingest "把这些新东西消化进知识库" Agent 读 raw 源材料,更新 concept/entity/source 页面,写交叉引用,更新 index
/memex:query "关于 X,我们知道哪些?" Agent 搜 wiki,综合答案,带引用
/memex:distill "这次对话有不少好结论,存下来" Agent 把会话要点蒸馏成结构化 raw 笔记
/memex:lint "检查一下知识库健不健康" Agent 跑机械检查 + 语义扫描,报问题,修问题
/memex:status "看看知识库现在什么状态" Agent 报告页面数、最近变化、待处理项

你不需要记住命令参数。你只需要用自然语言告诉 Agent 你想干什么,Agent 自己调对应的 slash command。

别上来就搞 RAG

一提"AI + 笔记",很多人的第一反应是搭 RAG:选 Embedding 模型、搭向量数据库、调切片策略。整套架构搞了一个月,笔记库里还是只有 20 篇文章。

Karpathy 的思路反过来:先跑通流程,再优化基础设施。 知识库规模不大的时候(几百篇文章以内),维护几个索引文件就够了。LLM 先读 index.md 定位,再直接阅读相关内容。简单、可靠、零额外成本。等你的笔记真的过了一万条,搜东西开始找不到、找不全了,再考虑 RAG 不迟。

这道理写代码的人都懂,但轮到自己搭知识库的时候就忘了。

每次问答也能存回知识库

还有一个 Karpathy 特别强调的设计:好的问答结果应该存回 wiki,而不是消失在聊天记录里。 你问了一个复杂问题,Agent 查 wiki、综合答案、带引用——这个答案本身就是一份有价值的知识产物。把它存成新页面。下次类似问题,Agent 直接读已有的分析,不用重新推导。

你每跟 AI 聊一次,知识库就增加一层。这就是复利。

知识编译管线:capture → ingest → query → lint


三、五个场景:memex 到底能带来什么价值

下面这五个场景,是我自己用了三个月的真实感受。

场景 1:长期研究 —— 让知识库自己长起来

痛点:你在研究"Agent Memory vs RAG"这个话题,今天看一篇论文,明天读一个开源项目,后天和 AI 讨论两个小时。三周后你想写篇总结文章——发现所有讨论散落在十几个聊天窗口里,找不到线索。

怎么做

你:/memex:capture https://arxiv.org/abs/xxxx --scene research
你:读到新的论文或讨论出新想法时,继续 capture 进去
你:积累几份材料后——
你:/memex:ingest 把这些新研究材料消化进 wiki
你:/memex:query "agent memory 和 RAG 的设计取舍,我们目前知道哪些?"

你始终在 agent 对话里。Agent 负责:

  • 把每篇论文、每次讨论存成 raw/research/ 下的源文件
  • ingest 时把新知识合并进 concepts/agent-memory.md、更新对比页 summaries/agent-memory-vs-rag.md
  • query 时综合 wiki 里的所有内容,带引用回答

价值:三周后,你拥有的不是十几个聊天窗口,而是一个结构化的知识地图——概念定义、方案对比、源材料索引、开放问题清单。写文章时,直接 /memex:query "agent memory 技术路线对比"

场景1:长期研究 — 知识库随时间生长

场景 2:长期项目 —— 让项目记忆可继承

痛点:你的项目已经迭代了三个月。今天用 Claude Code,明天用 Codex,后天用 Cursor。每个新 Agent 都要重新理解架构、踩过的坑、命名的原因、测试的边界。

怎么做

你:帮我连接这个项目到 memex 知识库
Agent:安装项目级别的 context 文件,记录相关的 scene

你:读当前代码和文档,然后起草这个项目的 architecturecommand-designknown-pitfalls 页面
Agent:读源码,写带有文件路径引用的 code-reading 笔记到 raw/

你:/memex:ingest 把这次 code-reading 结论写进项目 wiki
Agent:更新架构决策页、命令设计页、已知坑页、测试契约页

每次新会话开始:

你:/memex:query "继续 ai-memex-cli 网站和文档工作"
Agent:从 wiki 拉出最近的 handoff 笔记、未完成的任务、需要遵守的测试契约
你:从上次中断的地方继续

价值:项目知识不再是散落在聊天里的只言片语。新 Agent 开局就能回答"为什么这么设计"、"哪些地方容易踩坑"、"上次改到哪了"。代码仓库本身就是 source of truth,wiki 存的是 Agent 从代码、文档、issue、反馈中提炼出来的可继承理解

场景2:长期项目 — 三个 Agent 共用一个 wiki

场景 3:跨会话继承 —— 多次会话之间携带上下文

痛点:今天 Claude Code 做了一半,明天 Codex 继续,后天出差回来用 Cursor 检查。每个新会话都是一个黑洞——上下文全丢。

怎么做

你:/memex:distill 这次 Codex 会话,写清楚做到了哪、下一步做什么、有没有阻塞
Agent:找到当前 agent 的会话数据,蒸馏成 raw/sessions/ 下的结构化笔记

你:/memex:ingest 把这次 handoff 合并进项目记忆
Agent:更新项目 wiki 中的进度页和 log.md

——第二天,换了一个 agent——

你:/memex:query "上次中断的工作,下一步是什么"
Agent:从 wiki 里拉出 handoff 笔记和未完成项

跨 Agent 完全无感——Claude Code 写的,Codex 能读;Codex 补充的,Cursor 继续改。它们不共享一个聊天窗口,它们共享 raw/wiki/index.mdlog.md

价值:连续性不再绑定任何一个厂商。你可以换 Agent、换模型、等一周再回来,任务状态还在同一个 wiki 里等你。

场景3:跨会话继承 — 有无 memex 的对比

场景 4:对话沉淀 —— 把聊天里的好结论留下

痛点:一场深入对话里,你们讨论了产品定位、架构边界、某个 bug 根因、三个被否决的方案。聊完很爽,一周后只记得大概——细节全丢了。

怎么做

你:/memex:distill 这次对话,我们聊清楚了产品定位和几个关键的取舍
Agent:把对话蒸馏成 source 页,保留上下文、决策、未解问题

你:/memex:ingest 只要这次确定的稳定结论,合并到已有的 positioning 页面里
Agent:读蒸馏产物,提取可复用的结论,增量更新已有页面,不复制已有内容

什么样的结论值得沉淀?

  • 产品定位:怎么描述产品、避免用什么说法
  • 架构边界:为什么 CLI 不做语义层、为什么 raw 不可变
  • Bug 根因:排查路径、实际原因、回归测试要点
  • 被否决的方案:为什么没选、当时的前提是什么

价值:聊天不再是消耗品。重要推理先变成可追溯的 source,再变成结构化的 wiki 知识。下次 query 时,能同时看到结论和它为什么成立。如果前提变了,wiki 也能记录"老判断基于什么、新判断基于什么"。

场景4:对话沉淀 — 从聊天到 wiki 的蒸馏流

场景 5:结构化维护 —— 让 Agent 持续维护知识,而不是只回答一次

痛点:大部分人用 AI 的模式是"问一次答一次"。知识在回答完后原地消失。没人去更新、去合并重复页、去修断链、去标记过期内容。

怎么做

你:/memex:status
Agent:报告 vault 整体健康状况——页面数、最近更新的 source、哪些页面过时了、哪些维护任务待处理

你:/memex:lint 检查断链、孤儿页、过期页、缺失的 frontmatter
Agent:跑机械 lint(路径、链接、frontmatter 正确性)+ 语义扫描(矛盾、重复、过时论断)
你:机械问题直接修,语义问题先给我看方案
你:把 Karpathy 的 LLM Wiki gist 加入知识库
Agent:capture 源文件 → 创建 concept 页 → 更新相关页面交叉引用 → 写 log
你:告诉我改了什么,还有什么需要 review

价值:Wiki 不是一堆文件的堆积。它是一个被持续维护的结构化系统。每次 Agent 用它,也能同时改善它。重复页被合并或标注、孤儿页被找到、断链被修复、index.md 是真正的导航入口而非文件列表。

场景5:结构化维护 — lint 健康检查的四个维度


四、Agent 和 CLI 的分工边界

这里有一个设计决策需要讲清楚——CLI 永远只做机械正确性的事,不做语义判断。

谁负责 做什么
Agent Claude Code / Codex / Cursor 判断哪些页面要更新、哪些概念要链接、哪些矛盾要保留、哪些总结要重写
Slash Command /memex:capture 等 6 个 把用户的自然语言意图翻译成底层 CLI 调用
CLI memex 命令行工具 文件读写、frontmatter 校验、链接检查、关键词搜索、会话解析——纯机械,不调 LLM API

这意味着:

  • 你的知识不绑定任何厂商——Agent 可以换,wiki 不变
  • 你的知识是 Git 化的 Markdown——可以 diff、可以 blame、可以回退
  • CLI 永远不帮你做语义决策——"这两个页面是不是该合并"这种问题,Agent 自己判断但会问你

memex 三层架构:raw(不可变)→ wiki(编译)→ 输出

CLI 的补充能力

上面 6 个 slash command 覆盖日常 90% 的交互。CLI 底层还提供几个高阶能力,但不建议作为日常入口:

CLI 命令 用途 说明
memex watch 自愈守护进程 监听 raw/ 变化,自动触发 ingest → lint 循环。适合长期跑
memex inject 上下文注入 会话开始前,按任务描述从 wiki 拉最相关页面注入当前上下文
memex install-hooks 安装 Agent hooks 把 SessionStart / SessionEnd hook 写入 Agent 配置,自动 distill 和 inject
memex search 命令行搜索 全文搜索 wiki,适合脚本化场景

但这些不是入口。日常入口是 agent 对话框,是说 /memex:query 而不是敲 memex search


五、两周跑通最小闭环

如果你想试,不需要什么额外工具。装好 memex,在你的 Agent 里说话就行。

第一周:搭 raw → wiki 的最小循环。 装好 memex,运行 memex onboard。然后开始往知识库喂东西——看到好文章、好推文、好想法,直接对 Agent 说 /memex:capture。攒够 5 到 10 条后,说 /memex:ingest 把这些新素材消化进知识库。Agent 会生成摘要、提取概念、更新索引。

第二周:让问答开始积累,跑第一次健康检查。 每次对知识库做复杂提问,结果让 Agent 存回 wiki。然后说 /memex:lint 给知识库做一次全面体检。Agent 会扫出断链、矛盾、过期页、孤儿页——先让它修机械问题,语义问题你看一下再决定。

两周之后你有一个能持续运转的小系统。规模不重要,流程跑通了就行。后面就是往 raw/ 里不断喂素材,让 Agent 持续编译。


六、知识库的"GitHub 时刻"

回到 Karpathy。他那篇 Gist 的最后一句话是:

这套东西目前仍然像一堆 hacky scripts,但有空间做成 incredible new product。

我想到 2006 年前的版本控制。那时候也是 svn、cvs、git 命令行,只有程序员在用。然后有人把它做成了 GitHub,整个协作方式都变了。

个人知识库可能正在类似的节点。今天它是 Obsidian + LLM + 手搓脚本的组合,看起来还很粗糙。但底层范式已经有了:把知识当代码管理。 有输入,有编译,有产物,有测试。

如果你是程序员,好消息是你不需要学任何新东西。代码仓库怎么管,知识库就怎么管。你积累了这么多年的工程直觉,终于可以用在自己的笔记上了。

Karpathy 原文里还有一段话:

人类放弃 Wiki 是因为维护负担的增长速度永远超过它带来的价值。你得亲手写每个页面、手动保持一致性、记住所有交叉引用。

但 LLM 不会无聊。它可以一次触碰 10-15 个页面,把新知识合并进去,更新索引,同时保持系统自洽。

人的工作:策展、取舍、提问、思考。LLM 的工作:剩下的全部。

memex 做的,就是把这句话变成可以跑的东西。

别让你的笔记腐烂。让它们被编译。


快速开始:

npm install -g ai-memex-cli
memex onboard

然后在你的 Claude Code / Codex / Cursor 里说第一句话:

你:/memex:capture https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f --scene research
你:/memex:ingest Karpathy 的 LLM Wiki 思想,作为 research 场景的第一份材料
你:/memex:query "Karpathy 的 LLM Wiki 核心思想是什么?"

给 AI 一份会生长的记忆。


项目地址: github.com/zelixag/ai-…

理念来源: Karpathy's LLM Wiki Pattern

超越 Vibe Coding —— AI 辅助编程指南

作者 冴羽
2026年5月8日 18:22

你好,我是冴羽

用 AI 写代码,70% 的功能 5 分钟就能搞定,但剩下的 30% 能让你崩溃一整天。

我专门研究了 Google 工程师 Addy Osmani 写的《Beyond Vibe Coding》。

他用 25 年的开发经验告诉你:

  • Vibe Coding(氛围编程): 70% 进度 5 分钟,剩下 30% 要 3 天

  • AI-Assisted Engineering (AI 辅助工程):从原型到生产环境,全流程可控

两种方式的差距,就是“能跑的 Demo”和“能上线的产品”的差距。

1. 什么是 Vibe Coding?

1.1. 定义

Vibe Coding 是一种“随性”的开发方式:

你给 AI 一个模糊的需求,它给你一堆代码,你看都不看直接运行,关注的是整体“感觉”而不是实现细节。

特斯拉前 AI 总监 Andrej Karpathy 描述过这种未来:“我只是看看东西、说说话、跑跑代码、复制粘贴,然后它就能工作了。”

听起来很美好对吧?

1.2. 70% 问题

但现实是:Vibe Coding 能让你快速达到 70%,剩下 30% 会让你怀疑人生。

具体表现:

  • 两步前进,三步后退:修一个 Bug,冒出来三个新 Bug

  • 隐藏成本:没有工程知识,代码根本没法维护

  • 边际递减:AI 工具对有经验的开发者帮助更大,新手反而更容易踩坑

  • 安全漏洞:“Vibe Coding 很爽,直到你开始泄露数据库密码”

**但这并不是说:**Vibe Coding == 低质量代码。

它只是一种特定的开发方式,对于生产系统,你需要考虑的远不止“能跑”。

2. AI 编程 4 大坑

2.1. 坑 1:上来就让 AI 写代码

❌ 错误示范:“帮我做一个 Todo 应用”

✅ 正确示范:“给我几个 Todo 应用的架构方案,从最简单的开始。
先别写代码,只列出思路,让我选一个方向。”

🚀 最佳实践:写一个 mini PRD —— 定义问题、用户旅程、预期结果

这是因为 AI 十有八九会提出一个过于复杂的方案。你要先让它规划,再让它实现。

目前很多 AI 编程工具都支持“Plan Mode(规划模式)”:

  • Cline:先生成计划,再执行

  • Bolt:支持“Enhance Prompt”,把粗糙的想法变成结构化的需求

2.2. 坑 2:不提供文档就让 AI 写代码

  1. 检查知识门槛

很多模型只知道 Tailwind v3,但 v4 其实在 2025 年就发布了。

  1. 附上相关文件

当你用特定的 API 或框架时,把官方文档喂给 AI。

  1. 设置全局规则:
始终遵循这些原则:

1. 先定义数据模型,再写代码
2. 用 Mock 数据,别一上来就搞数据库
3. 创建组件库,把代码拆分到多个文件
4. 集中管理状态
5. 分批实现,别一次写太多
6. 改代码前确认改的是正确的文件
7. 需求不清楚就问

对应英文版:

// Example system prompt
Always follow these guidelines:

1. Define the data model before writing code
2. Start with mock data instead of a database
3. Create a component library and split code into multiple files
4. Centralize state management
5. Batch implementation into smaller chunks
6. Double-check you're changing the correct files
7. Ask follow-up questions if requirements are unclear

2.3. 坑 3:纯文字描述 UI

一张图胜过千言万语。 当你让 AI 实现设计或修 Bug 时,直接截图。

现在的 AI 编程工具都支持:

  • 从 Figma 导入设计:无缝集成设计和代码

  • 把图片添加到提示词:让 AI 理解视觉上下文

  • 引入实时浏览器截图:实时抓取页面状态

2.4. 坑 4:懒得测试

不管你多么小心,AI 总会在某个时刻破坏你的应用。

所以:

  • 每次更新后都在 localhost 测试

  • 打开浏览器控制台检查错误

  • 小步测试才能避免噩梦般的 Debug 过程

3. Prompt 工程 5 条原则

3.1. 提供足够的上下文

永远假设 AI 对你的项目一无所知。

❌ 错误:"为什么我的代码不工作?"

✅ 正确:"这个 React hooks 函数应该在表单提交时更新用户资料,
但现在报错'Cannot read property name of undefined'。
代码如下:

const updateProfile = (userData) => {
setUser(userData.name);
};

错误发生在第 2 行。使用 React 18.2.0。"

3.2. 明确你的目标

模糊的问题会得到模糊的答案。要具体说明:

  • 预期行为是什么

  • 当前(错误的)行为是什么

  • 相关的约束或要求

  • 期望的输出格式

3.3. 拆解复杂任务

把大问题分成小块,逐步推进。

举个例子:构建用户认证系统

  1. 首先:“设计用户认证的数据库 schema”

  2. 然后:“创建用户注册接口”

  3. 接着:“实现密码哈希和验证”

  4. 最后:“添加 JWT token 生成和验证”

3.4. 提供输入输出示例

用具体例子来减少歧义。

创建一个格式化货币的函数。

示例:

- formatCurrency(2.5) 应该返回 "$2.50"
- formatCurrency(1000) 应该返回 "$1,000.00"
- formatCurrency(0.99) 应该返回 "$0.99"

3.5. 使用角色和人设

让 AI “扮演”特定角色能改变回答的风格和深度。

有效的人设:

  • 资深 React 开发者:“作为一个资深 React 开发者,review 我的代码找潜在 Bug”

  • 性能专家:“你是 JavaScript 性能专家,优化下面这个函数”

  • 代码审查员:“以安全专家的角度 review 这段代码”

4. 生产代码 4 条原则

4.1. 始终 Review AI 生成的代码

把 AI 生成的代码当作初级开发者写的代码。 需要仔细 review 和测试才能提交。

列出检查清单:

  • ✅ 安全漏洞

  • ✅ 错误处理

  • ✅ 性能影响

  • ✅ 可维护性标准

4.2. 有完整的测试策略

AI 可以帮你生成测试,但你必须验证覆盖率和质量。

"为这个用户认证函数生成完整的单元测试。包括:

- 有效凭证
- 无效凭证
- 网络错误
- 畸形输入
- 边界情况如空字符串
- 安全场景如 SQL 注入尝试

使用 Jest,遵循我们在 /tests/auth/ 的现有测试模式"

4.3. 安全优先

AI 可能引入安全漏洞。始终验证安全实践。

累出安全验证清单:

  • ✅ 输入验证和清理

  • ✅ 认证和授权

  • ✅ SQL 注入防护

  • ✅ XSS 防护

  • ✅ 敏感数据处理

  • ✅ API key 和凭证管理

  • ✅ HTTPS 和安全通信

4.4. 性能和可扩展性

AI 可能生成能用但低效的代码。始终考虑性能。

"优化这个数据库查询,表有 100 万+记录。考虑:

- 合适的索引策略
- 查询执行计划
- 内存使用
- 连接池
- 缓存机会

解释你的优化选择,包括优化前后的性能对比。"

5. 未来 AI 辅助开发的完整工作流

想象一下这样的开发体验:

🎯 意图定义 → 用自然语言描述你想构建什么,AI 理解上下文、需求和约束

📋 智能规划 → AI 生成详细的技术方案,考虑架构决策,建议最优实现策略

🏗️ 自主实现 → AI agent 跨多个文件实现功能,处理集成,生成完整测试

🔍 智能 Review → AI 提供详细的代码审查、安全分析和性能优化建议

🚀 自动部署 → AI 管理部署流程,监控性能,提供优化建议

这其实都不算是未来了,而是现在已经在跑的东西。

我是冴羽,10 年笔耕不辍,专注前端领域,更新了 10+ 系列、300+ 篇原创技术文章,翻译过 Svelte、Solid.js、TypeScript 文档,著有小册《Next.js 开发指南》、《Svelte 开发指南》、《Astro 实战指南》。

欢迎围观我的“网页版朋友圈”,关注我的公众号:冴羽(或搜索 yayujs),每天分享前端知识、AI 干货。

面试官:说一下你现在使用的 AI IDE,什么,JoyCode 是什么?

作者 海石
2026年5月8日 19:06

前言

面试官:同学你好,先自我介绍一下吧

我:好嘞!我叫海石,是一位能工智人🤖

面试官:?

ME1778235521506.png

我:哦不是,是一位在AI Coding上有一定经验的研发...😁

面试官:既然你都这么说了,那你日常在用什么 AI IDE ?

我:JoyCode!

面试官:嗯嗯,Claude Co我也用,确实好……等等,JoyCode?这是什么?

我:点击此处,快速访问官网呢亲

咳咳,节目效果到此为止

我想不少掘友,听到 JoyCode 的第一反应都是"啥?没听过 。"

⬇️从谷歌搜索指数中也可见一斑⬇️

2026-02-28-17-10KPYi8S105LHDUeeV.png

作为一个已经把 JoyCode 当成"靠谱队友"用了大半年的京东 JDer,我觉得是时候认真 聊一聊:JoyCode 到底是什么?它好用吗"?

image.png

(个人的分享往往是有限的,一些最佳实践随着技术的迭代,模型的升级,往往也会过时,因此本文只是一次抛砖引玉,欢迎掘友们交流)


「观前叠甲」

1、有些案例现在看确实陈旧,比如rules+mcp,但其实是当初25年mcp比较热门时,skill的概念还没推出时,社区里也比较推崇的方式,大家不妨以一种回顾一路走来的AI Coding发展历史的心态阅读

2、从Vibe Coding 到 Context、SDD、到Agentic Coding(Multi-Agent)、再到Harness Engineering,AI时代给人一种“只要我学得晚,我就可以什么都不用学”的感受😂

3、笔者个人目前最常用的(门槛也最低)方式还是SDD 或者 有时候就是安装一些best practice的skills,开个plan模式,基本上就足够日常开发需求了

至于参考OpenAI的实践,用Harness Engineering的思路对当前工程仓库进行改造,我们可以下回一起聊一聊,不仅仅只是停留于概念的解释,而是真的结合业务需求进行实战

看看这样做了,编码质量到底能提高多少


一、JoyCode 到底是什么?

借用官网的一句话:JoyCode,专为应对企业级复杂任务而设计的智能编码工具

适合在以下场景使用:

  • 企业复杂任务场景:助力对业务需求精准理解,代码仓库的深度解析
  • 需要开箱即用的全流程智能开发体验:JoyCode 提供完整的 AI 辅助开发体验
  • 寻求 AI 辅助编程提升效率的开发者:利用 AI 能力加速开发过程
  • 需要智能代码补全和实时编程建议的场景:提高编码效率和质量

以具体业务需求开发为例,聊聊我的"编程队友"JoyCode 是怎么为我提效的


二、并行任务

先来还原一个我上周二下午的真实状态:

我裂开了

1️⃣ xx系统的页面要补监控配置——监控不能等;

2️⃣ 测试同事在群里 @ 我:"那 份埋点数据呢?"——人不能等;

3️⃣ 产品又甩来一个新需求:"这个加一下,今天能上吗 ?"——需求也不能等。

image.png

放在以前,这种时候我只有两个选择:

  • A. 加班
  • B. 报风险,然后被产品蛐蛐

但现在,我可以把新需求的代码实现交给 JoyCode,我自己专心搞前两件 。

这就是我想说的第一个核心用法:异步协作

很多人对 AI 写代码的印象是:"写得快,但写得野。"

变量随便起、组件库瞎引、规范全靠猜——交付出来的代码我还得花一个小时给它"擦屁股", 那不如自己写。

那有没有办法让 AI 既写得快、又写得"懂规矩"?

有,Rules + MCP

Step 1:给 JoyCode 配 Rules,把它从"专门"变成"专业"

JoyCode 支持类似 Cursor 的 .mdc 规则文件,配置入口在这里 👇

image.png

Rule 创建

不知道写什么 Rules?给大家推荐一个 star 接近 3k 的开源项目:

awesome-cursor-rules-mdc

👉 awesome-cursor-rules-mdc 👈

各种语言、各种框架的 Rules 应有尽有,基本是开箱即用。

创建好的 Rule

创建好的 Rule 会落到这个目录里

Rules 目录

Step2:基于业务实践沉淀Rule

通用的 Rules 解决不了"业务私有"的问题。

我自己负责的项目用的是京东自研的组件库,其中 jd-icon 在 Vue2 兼容写法 和 Vue3 <script setup> 写法下,用法完全不同——这种"内部知识"AI 是不可能自己悟出来的。

于是我手搓了一份 dong-design-icon.mdc

把"踩过的坑"沉淀成 Rule,这一步看着繁琐,但ROI很高——AI 再也不会写出 <jd-icon icon="plus" /> 这种"四不像"了

Step 3:MCP 一开, 直通"京东内网生态"

京东内部各中台沉淀了很多mcp工具提供给上下游

这提高了我们用大模型进行编码的质量,减少了返工

三、新版本体验

(截止本文发布,版本已经更新至2.6.x)

JoyCode 从 v0.5.0 一路更新到 v2.3.6,最新的 v3.0.0 Preview 也已经在路上。它的更新主题是「提效、智能、便捷

版本一览

「便捷」:满分 10 分,我打 8.4分,因为确实有1.6

✅ 拖拽文件、一键添加上下文

拖拽文件

✅ 一键选中代码加上下文 + Cmd + L 快捷键

快捷键

✅ Auto 模式默认选中

Auto 模式

减法美学,给减负点赞。

✅ AI 提交行数披露

提交行数

从 v2.3.4 升到 v2.3.6,统计准确多了,看着有成就感。😋

✅ Repo Wiki:基于当前工程仓库生成WIKI,类似Zread 和 DeepWiki


四、技术氛围

公司内部的技术氛围还是不错的,不过这个还是“因组而异”

经常有这样一句话流传:同一个公司组和组之间的区别,可能比公司与公司之间的区别还要大

我们组每周会组织AI相关的分享,包括但不限于AI提效范式的探讨、CopilotKit、AGUI协议、A2UI的实践、AI Coding的干货技巧等等

个人觉得组里对新技术和AI前沿还是很看重


五、结语

AI时代大家不可避免的会感到焦虑

前端已死都不知道听了多少回了

之前逛论坛和社区也会经常看到这样一张图:

守旧派如何如何

维新派如何如何

以及觉得AI已经可以替代程序员的人又如何如何

我的想法不多,如图所示,与君共勉

结束

生成式 UI 藏大招!看似露营案例,实则电商集成 GenUI SDK 干货

2026年5月7日 17:29

本文由云软件体验技术团队岑灌铭原创。从露营趣味案例入手,详解电商系统集成 GenUI SDK 完整实操~

背景

时针拨过周一晚上十点,XX大学男生寝室 502 里充斥着键盘敲击声和偶尔的鼾声。大二学生小明,一个典型的“行动派热血青年”,正瘫在床上刷着朋友圈。

突然,他的手指停住了。屏幕上是隔壁班班花发的一组九宫格:精致的摩洛哥风帐篷、摇曳的煤油灯、噼啪作响的篝火,背景是浩瀚星空。配文:“周末,逃离城市,枕着星星入眠。”

小明感觉心脏被重重击中了。一种名为“我也要去”的冲动像野火一样在胸中燃烧。 “这才是大学生活!我也要去露营!就这周末!”

1.png

作为小白,没有经验的他只好求助AI。根据推荐清单,小明在某某电商网站中,完成多轮“搜索-挑选-加购”后。结算时才发现超预算了。囊中羞涩的小明发出了悲怨之声,老王在了解了情况后。给他推荐了一个神奇的网站。小明输入完露营需求后,导购助手自动为他推荐了露营的高性价比装备。 小明可以轻松地完成一键加购和结算。

下面就来看一下这个神奇的电商网站:

2.gif

智能导购背后的“黑科技”

这个神奇网站的智能导购助手,正是基于 OpenTiny GenUI SDK 开发而成的。它是 OpenTiny 团队基于生成式 UI(Generative UI)理念倾力打造的开源开发方案,具备完备的前后端一体化集成能力。

在之前的文章中,我们曾介绍过 GenUI SDK 的核心能力与开发特性,错过的同学可以点击回顾:

在大家对 GenUI 的基本概念有所了解后,下面我们将深入剖析这个“导购助手”的具体实现逻辑。通过下方的详细集成指导,你可以按照手册步骤,一步步在自己的项目中复刻这种智能交互体验。

💡 小贴士:  如果你觉得这个案例对你的项目有启发,或者想一窥“智能导购”背后的源码实现,欢迎访问我们的 GitHub 仓库:github.com/opentiny/ge…

点上 Star ⭐ 不迷路,不仅是对开源精神的支持,也方便你日后随时定位组件用法与技术文档!

集成指导

在开始集成之前,需要先下载相关源码。下面附上集成前的原始工程与集成完成后的完整示例,方便对照参考:

Demo 工程地址: github.com/opentiny/ge…

集成对比分支:

  • 集成前(原始电商工程):raw-e-commerce
  • 集成后(完成智能导购集成):main

如果你更喜欢边看边做,我们也准备了生成式 UI 专题直播的完整回放,其中包含手把手的代码实战环节,跟着视频回放中的代码实战环节一步步操作,轻松复刻完整效果:

直播回放:www.bilibili.com/video/BV1DM…

1. 集成目标

在电商前端系统中集成「AI 导购助手」功能,实现以下核心能力,为用户提供智能、便捷的购物引导体验:

  • 通过 GenuiChat 组件展示对话界面及生成式 UI 内容,实现自然交互
  • 借助 MCP 工具调用电商系统原生能力,实现商品实时搜索
  • 渲染与电商系统风格统一的自定义商品卡片组件,保证视觉一致性
  • 支持 AI 触发核心业务交互:商品加购、商品详情跳转、购物车跳转

2. 前置准备

2.1 环境要求

确保本地开发环境满足以下版本要求,避免依赖兼容问题:

  • Node.js 版本 ≥ 18
  • pnpm 版本 ≥ 10

2.2 安装项目依赖

在项目仓库根目录执行以下命令,安装项目基础依赖:

pnpm install

依赖安装完毕后,就可以启动并体验一下原始电商系统了~

运行以下命令可以运行电商系统项目

pnpm -F e-commerce dev

运行成功后,点击控制台的链接跳转到浏览器就可以看到商城的首页了:

3.png

2.3 启动 GenUI 后端服务

按照以下步骤启动 GenUI 后端服务,为前端提供大模型对话能力:

  1. 进入项目 server目录,复制环境变量示例文件并进行配置: cd server然后cp .env.example .env
  2. 编辑 server/.env 文件,至少配置以下核心参数(确保服务正常运行):
    1. API_KEY:你的模型服务密钥(必填)
    2. BASE_URL:模型服务地址(需兼容 OpenAI 接口格式,必填)
    3. PORT:服务运行端口(默认值为 3100,可按需修改)
  3. server目录下运行命令: pnpm dev

服务启动成功后,控制台会输出提示信息:genui-sdk-server is running on http://localhost:3100 说明:大模型对话接口地址为 http://localhost:3100/chat/completions,后续前端将通过该接口与后端交互。
至此,GenUI 后端服务准备完成,接下来进行前端项目改造,实现智能导购助手的集成。

3. 前端安装 GenUI 相关依赖

在 packages/e-commerce 目录下,需安装以下 GenUI 相关依赖,用于实现对话组件、MCP 工具调用等功能:

  • @opentiny/genui-sdk-vue:GenUI 核心组件库(提供 GenuiChat 等组件)
  • @modelcontextprotocol/sdk:MCP 协议 SDK,用于工具开发与调用
  • zod:参数校验工具,确保接口及工具调用参数规范
  • openai:OpenAI 兼容接口 SDK,用于与大模型交互

在仓库根目录执行以下命令,精准安装依赖至 e-commerce 包:

pnpm -F e-commerce add @opentiny/genui-sdk-vue @modelcontextprotocol/sdk openai zod

4. 集成 GenuiChat:生成式 UI 初体验

先实现最小可运行版本的 AI 导购助手,核心目标:成功打开对话界面,发送消息后能正常接收大模型返回结果,验证基础交互链路通畅。

4.1 新建 AI 对话助手组件

在 src/components 目录下创建 AIAssistantDrawer.vue组件,实现 AI 导购助手的侧边抽屉布局、对话窗口及基础操作功能,代码如下:

代码核心功能:创建 AI 导购助手侧边抽屉组件,包含布局、对话窗口及打开/关闭、新建对话等基础操作

<script setup lang="ts">
import { computed, ref, type ComponentPublicInstance } from 'vue'
import { GenuiChat, GenuiConfigProvider } from '@opentiny/genui-sdk-vue'

const props = defineProps<{
  modelValue: boolean
}>()

const emit = defineEmits<{
  (e: 'update:modelValue', value: boolean): void
}>()

type GenuiChatExposed = ComponentPublicInstance & {
  handleNewConversation: () => void
}

const chatRef = ref<GenuiChatExposed | null>(null)
const theme = ref<'dark' | 'lite' | 'light'>('light')
const model = ref('deepseek-v3.2')
const temperature = ref(0)

const chatConfig = {
  addToolCallContext: false,
  showThinkingResult: true,
}

const chatUrl = 'http://localhost:3100/chat/completions'

function closeDrawer() {
  emit('update:modelValue', false)
}

function startNewConversation() {
  chatRef.value?.handleNewConversation()
}

</script>

<template>
  <Teleport to="body">
    <Transition name="drawer-fade">
      <div v-if="modelValue" class="assistant-layer" @click.self="closeDrawer">
        <aside class="assistant-drawer" aria-label="AI 导购助手">
          <header class="assistant-drawer__header">
            <div>
              <h2>AI 导购助手</h2>
            </div>
            <div class="assistant-drawer__actions" role="toolbar" aria-label="助手操作">
              <button type="button" class="assistant-drawer__action" @click="startNewConversation">
                新建对话
              </button>
              <button type="button" class="assistant-drawer__close" @click="closeDrawer">关闭</button>
            </div>
          </header>

          <section class="assistant-drawer__content">
            <div class="assistant-chat">
              <GenuiConfigProvider :theme="theme">
                <GenuiChat
                  ref="chatRef"
                  :url="chatUrl"
                  :model="model"
                  :temperature="temperature"
                  :chat-config="chatConfig"
                />
              </GenuiConfigProvider>
            </div>
          </section>
        </aside>
      </div>
    </Transition>
  </Teleport>
</template>

<style scoped>
.drawer-fade-enter-active,
.drawer-fade-leave-active {
  transition: opacity 0.2s ease;
}

.drawer-fade-enter-from,
.drawer-fade-leave-to {
  opacity: 0;
}

.assistant-layer {
  position: fixed;
  inset: 0;
  z-index: 75;
  background: rgba(17, 8, 38, 0.26);
  display: flex;
  justify-content: flex-end;
}

.assistant-drawer {
  width: min(600px, 95vw);
  height: 100%;
  background: #fcf9ff;
  border-left: 1px solid #e5d9ff;
  box-shadow: -12px 0 34px rgba(20, 8, 41, 0.24);
  display: flex;
  flex-direction: column;
}

.assistant-drawer__header {
  padding: 16px;
  border-bottom: 1px solid #eadfff;
  display: flex;
  justify-content: space-between;
  gap: 12px;
}

.assistant-drawer__header h2 {
  margin: 0;
  color: #20133c;
  font-size: 18px;
}

.assistant-drawer__header p {
  margin: 4px 0 0;
  color: #73668d;
  font-size: 12px;
}

.assistant-drawer__actions {
  display: flex;
  flex-shrink: 0;
  align-items: flex-start;
  gap: 8px;
}

.assistant-drawer__action,
.assistant-drawer__close {
  border: 1px solid #ddcff9;
  background: #fff;
  color: #5e4e79;
  border-radius: 8px;
  height: 32px;
  padding: 0 10px;
  cursor: pointer;
  font-size: 13px;
  white-space: nowrap;
}

.assistant-drawer__action {
  border-color: #c4b5fd;
  color: #4c1d95;
  background: #f5f3ff;
}

.assistant-drawer__action:hover {
  background: #ede9fe;
}

.assistant-drawer__content {
  min-height: 0;
  flex: 1;
}

.assistant-chat {
  height: 100%;
}

.assistant-chat :deep(.tiny-config-provider) {
  height: 100%;
}

@media (max-width: 760px) {
  .assistant-drawer {
    width: 100vw;
  }
}
</style>

组件创建完成后,需在 App.vue 中引入并使用,同时添加悬浮球控件,用于控制 AI 导购助手的显示与隐藏。

引入并使用 AIAssistantDrawer 组件

核心功能:在 App.vue 中引入 AI 导购助手组件,并定义控制组件显示/隐藏的响应式变量

import AIAssistantDrawer from './components/AIAssistantDrawer.vue'
import { ref } from 'vue'

const assistantOpen = ref(false)

在模板中添加悬浮球控件(任意位置)

核心功能:添加悬浮球按钮,点击可打开 AI 导购助手,同时显示待结算商品数量

<button
  class="assistant-fab"
  type="button"
  aria-label="打开 AI 导购助手"
  @click="assistantOpen = true"
>
  <span class="assistant-fab__dot">AI</span>
  <span class="assistant-fab__text">
    导购助手
    <small v-if="totalCount > 0">{{ totalCount }} 件待结算</small>
  </span>
</button>

<AIAssistantDrawer
  v-if="assistantOpen"
  v-model="assistantOpen"
/>

配置悬浮球样式

核心功能:设置悬浮球的样式、位置、交互效果,确保与电商系统视觉风格统一

.assistant-fab {
  position: fixed;
  right: 18px;
  bottom: 20px;
  z-index: 72;
  border: 0;
  border-radius: 999px;
  background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%);
  color: #fff;
  min-height: 52px;
  padding: 8px 14px 8px 10px;
  display: inline-flex;
  align-items: center;
  gap: 10px;
  box-shadow: 0 10px 24px rgba(77, 46, 141, 0.28);
  cursor: pointer;
}

.assistant-fab__dot {
  width: 34px;
  height: 34px;
  border-radius: 50%;
  display: grid;
  place-items: center;
  font-size: 12px;
  font-weight: 700;
  background: rgba(255, 255, 255, 0.18);
}

.assistant-fab__text {
  display: grid;
  text-align: left;
  gap: 1px;
  font-size: 13px;
  font-weight: 700;
  line-height: 1.2;
}

.assistant-fab__text small {
  font-size: 11px;
  font-weight: 500;
  opacity: 0.88;
}

4.2 初体验验证

启动前端项目后,点击页面右下角的 AI 导购助手悬浮球,验证以下基础功能是否正常:

  • AI 助手侧边抽屉能正常打开、关闭
  • 聊天框能正常显示,支持输入消息
  • 输入消息后,能正常接收大模型返回的内容,体验生成式 UI 基础能力

测试建议:输入简单问候语(如“你好呀!”),查看大模型返回结果是否正常。

4.png

目前生成式 UI 已成功集成到电商系统中,但此时的生成式 UI 与电商系统核心业务完全独立,无法实现商品搜索、加购等电商相关功能。接下来,我们将接入电商系统原生能力,将生成式 UI 与电商业务深度融合,打造真正的智能导购助手。

首先,在 src 目录下新建 genui 文件夹,用于存放生成式 UI 相关的工具、组件、交互配置等文件,统一管理相关代码。

5. 集成 MCP:商品查询能力赋能

智能导购助手的核心能力是根据用户需求推荐商品,因此需将电商系统中原有的商品搜索能力,通过 MCP(Model Context Protocol)工具封装,接入到 AI 助手当中,实现商品实时查询与推荐。

5.1 MCP 工具开发

在 src/genui/mcp 目录下新建 product-mcp.ts 文件,开发商品搜索 MCP 工具。

核心功能:开发商品搜索 MCP 工具,封装电商商品查询接口,定义参数校验、返回格式,供 AI 助手调用

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
import { z } from 'zod'
import { searchProducts } from '../../api'
import type { Product } from '../../types'

export const SEARCH_PRODUCTS_TOOL = 'search_products'

export const SearchProductsArgsSchema = z.object({
  keyword: z.string().min(1, 'keyword 不能为空'),
  limit: z.number().int().min(1).max(10).optional(),
})

export const ProductSchema = z.object({
  id: z.string(),
  title: z.string(),
  price: z.number(),
  image: z.string(),
  description: z.string(),
  tags: z.array(z.string()),
  rating: z.number(),
  ratingCount: z.number(),
  inStock: z.boolean(),
  badgeText: z.string(),
})

export const SearchProductsResultSchema = z.object({
  tool: z.literal(SEARCH_PRODUCTS_TOOL),
  keyword: z.string(),
  total: z.number().int().min(0),
  found: z.boolean(),
  results: z.array(ProductSchema),
})

export type SearchProductsArgs = z.infer<typeof SearchProductsArgsSchema>
export type SearchProductsResult = z.infer<typeof SearchProductsResultSchema>

async function searchProductsByBusiness(keyword: string, limit = 4): Promise<Product[]> {
  const results = await searchProducts(keyword)
  return results.slice(0, limit)
}

export function createProductMcpServer() {
  const server = new McpServer(
    { name: 'e-commerce-product-mcp-server', version: '1.0.0' },
    {},
  )

  server.registerTool(
    SEARCH_PRODUCTS_TOOL,
    {
      title: '搜索商品',
      description: '根据关键词在商品库中搜索商品',
      inputSchema: SearchProductsArgsSchema,
    },
    async (rawArgs) => {
      const parsedArgs = SearchProductsArgsSchema.safeParse(rawArgs)
      if (!parsedArgs.success) {
        throw new Error('参数校验失败')
      }

      const { keyword, limit = 4 } = parsedArgs.data
      const results = await searchProductsByBusiness(keyword, limit)

      const payload = SearchProductsResultSchema.parse({
        tool: SEARCH_PRODUCTS_TOOL,
        keyword,
        total: results.length,
        found: results.length > 0,
        results,
      })

      return {
        content: [{ type: 'text', text: JSON.stringify(payload) }],
      }
    },
  )

  return server
}

5.2 MCP Client 开发

开发完 MCP 工具(服务端)后,需开发 MCP 客户端,用于调用该工具。由于 MCP 服务端与客户端运行在同一服务中,因此采用 InMemoryTransport 方式实现两者的通信,无需额外配置网络接口。

在 src/genui/mcp 目录下新建 mcp-client.ts 文件,编写 MCP 客户端代码。

核心功能:开发 MCP 客户端,通过内存通信方式连接 MCP 服务端,提供工具列表获取、工具调用等能力

import OpenAI from 'openai'
import { Client } from '@modelcontextprotocol/sdk/client'
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
import { createProductMcpServer } from './product-mcp'

let clientPromise: Promise<Client> | null = null

async function createClient() {
  const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair()
  const server = createProductMcpServer()
  await server.connect(serverTransport)

  const client = new Client({ name: 'e-commerce-product-mcp-client', version: '1.0.0' }, {})
  await client.connect(clientTransport)
  return client
}

export function getMcpClient() {
  if (!clientPromise) clientPromise = createClient()
  return clientPromise
}

export async function getOpenAITools() {
  const client = await getMcpClient()
  const raw = await (client as unknown as { listTools: () => Promise<{ tools?: Array<Record<string, unknown> }> }).listTools()
  const tools = Array.isArray(raw?.tools) ? raw.tools : []
  return tools
    .filter((tool) => typeof tool?.name === 'string')
    .map(
      (tool) =>
        ({
          type: 'function',
          function: {
            name: tool.name as string,
            description: typeof tool.description === 'string' ? tool.description : '',
            parameters:
              tool.inputSchema && typeof tool.inputSchema === 'object'
              ? (tool.inputSchema as Record<string, unknown>)
              : { type: 'object', properties: {} },
          },
        }) as OpenAI.Chat.Completions.ChatCompletionTool,
    )
}

export async function callMcpToolAsText(name: string, args: Record<string, unknown> = {}) {
  const client = await getMcpClient()
  const result = await client.callTool({ name, arguments: args })
  const content = Array.isArray((result as { content?: unknown }).content)
    ? ((result as { content: Array<{ type?: string; text?: string }> }).content ?? [])
    : []
  const text = content.find((item) => item.type === 'text' && typeof item.text === 'string')?.text
  return text ?? JSON.stringify(result)
}

5.3 自定义 fetch:实现工具调用与流式返回

要将 MCP 工具能力接入 AI 导购助手,需通过自定义 fetch 方法,处理大模型的工具调用请求、多轮交互及结果流式返回逻辑,确保工具调用过程流畅、符合电商业务场景。

在 src/genui/mcp 目录下新建 custom-fetch.ts 文件,编写自定义 fetch 逻辑。

核心功能:自定义 fetch 方法,处理大模型工具调用、多轮交互及流式返回,对接 MCP 工具与 AI 助手

import OpenAI from 'openai'
import type { CustomFetch } from '@opentiny/genui-sdk-vue'
import { getOpenAITools, callMcpToolAsText } from './mcp-client'

interface OpenAIFetchConfig {
  apiKey: string
  baseURL?: string
  defaultModel?: string
  maxToolSteps?: number
}

type ParsedRequestBody = {
  model?: string
  temperature?: number
  messages?: unknown[]
}

type ToolCallDelta = {
  index?: number
  id?: string
  function?: {
    name?: string
    arguments?: string
  }
}

type ToolCall = {
  id: string
  type: 'function'
  function: {
    name: string
    arguments: string
  }
}

function encodeSseChunk(encoder: TextEncoder, data: unknown) {
  return encoder.encode(`data: ${JSON.stringify(data)}\n\n`)
}

function parseRequestBody(body: string): ParsedRequestBody {
  try {
    return JSON.parse(body) as ParsedRequestBody
  } catch {
    return {}
  }
}

function accumulateToolCalls(target: ToolCall[], deltas: ToolCallDelta[]) {
  for (const delta of deltas) {
    const index = delta.index ?? 0
    const item = (target[index] ??= {
      id: delta.id ?? '',
      type: 'function',
      function: { name: '', arguments: '' },
    })

    if (delta.id) item.id = delta.id
    if (delta.function?.name) item.function.name += delta.function.name
    if (delta.function?.arguments) item.function.arguments += delta.function.arguments
  }
}

async function executeToolCall(toolCall: ToolCall, currentMessages: unknown[]) {
  const createResult = (result: string) => {
    currentMessages.push({ role: 'tool', tool_call_id: toolCall.id, content: result })
    return {
      id: toolCall.id,
      type: 'function',
      function: {
        name: toolCall.function.name,
        arguments: toolCall.function.arguments,
        result,
      },
    }
  }

  try {
    const result = await callMcpToolAsText(toolCall.function.name, JSON.parse(toolCall.function.arguments || '{}'))
    return createResult(JSON.stringify(result))
  } catch (error) {
    return createResult(
      JSON.stringify({
        error: error instanceof Error ? error.message : '工具执行失败',
      }),
    )
  }
}

const systemPrompt = `
你是一个电商导购助手,你的任务是根据用户的需求,推荐商品。禁止使用mock数据,必须使用mcp工具获取商品数据。
你的可展示区域宽度不大,请注意你的布局。商品卡片宽度差不多占满了显示区域,请注意排版,单行只可以放一张商品卡片。
如果缺少的商品,请提示用户,让用户自行通过其他方式购买。
`

export function createMcpOpenAICustomFetch(config: OpenAIFetchConfig): CustomFetch {
  const openai = new OpenAI({
    apiKey: config.apiKey,
    baseURL: config.baseURL,
    dangerouslyAllowBrowser: true,
  })

  const maxToolSteps = config.maxToolSteps ?? 20

  return async (
    _url: string,
    options: {
      method: string
      headers: Record<string, string>
      body: string
      signal?: AbortSignal
    },
  ) => {
    const req: any = parseRequestBody(options.body)

    const encoder = new TextEncoder()

    const stream = new ReadableStream<Uint8Array>({
      async start(controller) {
        try {
          let step = 0
          const currentMessages = [{ role: 'system', content: systemPrompt }, ...req.messages]

          const tools = await getOpenAITools()

          while (step < maxToolSteps) {
            const completion = await openai.chat.completions.create(
              {
                ...req,
                messages: currentMessages as OpenAI.Chat.Completions.ChatCompletionMessageParam[],
                tools,
                tool_choice: 'auto',
                stream: true,
              },
              { signal: options.signal },
            )

            const toolCalls: ToolCall[] = []
            let hasToolCall = false
            let shouldContinue = false

            for await (const chunk of completion) {
              const choice = chunk.choices?.[0]
              if (!choice) continue

              if (choice.delta.tool_calls && choice.delta.tool_calls.length > 0) {
                hasToolCall = true
                accumulateToolCalls(toolCalls, choice.delta.tool_calls as ToolCallDelta[])
              }

              controller.enqueue(encodeSseChunk(encoder, chunk))

              if (choice.finish_reason === 'tool_calls' && toolCalls.length > 0) {
                currentMessages.push({
                  role: 'assistant',
                  content: null,
                  tool_calls: toolCalls,
                })

                const toolResults = await Promise.all(
                  toolCalls.map(async (item, index) => ({ ...(await executeToolCall(item, currentMessages)), index })),
                )

                controller.enqueue(
                  encodeSseChunk(encoder, {
                    id: chunk.id,
                    object: 'chat.completion.chunk',
                    model: chunk.model,
                    created: chunk.created || Math.floor(Date.now() / 1000),
                    choices: [
                      {
                        index: 0,
                        delta: { tool_calls_result: toolResults },
                        finish_reason: 'tool_calls',
                      },
                    ],
                  }),
                )

                shouldContinue = true
                break
              }

              if (choice.finish_reason && choice.finish_reason !== 'tool_calls') {
                shouldContinue = false
                break
              }
            }

            step += 1
            if (!hasToolCall || !shouldContinue) break
          }

          controller.enqueue(encoder.encode('data: [DONE]\n\n'))
          controller.close()
        } catch (error) {
          controller.enqueue(
            encodeSseChunk(encoder, {
              error: {
                message: error instanceof Error ? error.message : 'customFetch 处理失败',
                type: 'custom_fetch_error',
              },
            }),
          )
          controller.error(error)
        }
      },
    })

    return new Response(stream, {
      status: 200,
      headers: {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
        Connection: 'keep-alive',
      },
    })
  }
}

6. 引入自定义 fetch:实现工具调用

自定义 fetch 编写完成后,需在 AIAssistantDrawer.vue 组件中引入并配置,将其传入 GenuiChat 组件,实现 AI 助手对 MCP 商品搜索工具的调用。

修改 AIAssistantDrawer.vue 组件,引入自定义 fetch 并配置相关参数:

import { createMcpOpenAICustomFetch } from '../genui/mcp/custom-fetch'

// ...省略部分代码
// 在model定义的后面创建自定义fetch
const customFetch = createMcpOpenAICustomFetch({
  apiKey: 'sk-trial',
  baseURL: 'http://localhost:3100',
  defaultModel: model.value,
  maxToolSteps: 20,
})

修改 GenuiChat 组件的使用方式,添加 custom-fetch 属性,传入配置好的自定义 fetch:

<GenuiChat
    ref="chatRef"
    :url="chatUrl"
    :model="model"
    :temperature="temperature"
    :custom-fetch="customFetch"
    :chat-config="chatConfig"
/>

配置完成后,重新启动前端项目,打开 AI 导购助手,输入测试需求:“我想去露营!但我没经验,也没装备,预算 1500 元以内!”,验证工具调用功能是否正常。

预期效果:AI 助手会自动调用 MCP 商品搜索工具,根据“露营装备”“预算 1500 元以内”等关键词搜索商品,并通过多轮工具调用优化推荐结果,最终生成商品卡片。

5.png

注意:此时生成的商品卡片样式由大模型随机生成,与电商系统原生商品卡片样式不一致,且点击“加入购物车”等按钮无法触发实际业务操作,用户体验存在明显短板。

6.png

目前在自定义组件与自定义交互的体验上,仍有部分细节不够完善,整体使用感受尚未达到理想状态。接下来,我们就围绕这两部分进行优化,让组件表现与交互行为更加贴合电商系统原生体验,实现无缝衔接。

7. 自定义组件:复刻原生系统体验,保持一致交互质感

为解决商品卡片样式与电商系统不一致的问题,我们将复用电商系统中原有的 ProductCard.vue 商品卡片组件,通过 GenUI 自定义组件配置,让 AI 助手生成的商品卡片与系统原生样式完全统一,保证视觉与交互的一致性。

在 src/genui/chat 目录下新建 custom-components.ts 文件,配置自定义商品卡片组件,明确组件参数、事件及使用规范,供大模型理解和调用:

import ProductCard from '../../components/ProductCard.vue'

export const customComponents = [
  {
    component: 'ProductCard',
    name: '导购商品卡片',
    description:
      '展示推荐商品信息,单张卡片宽度是600px,请注意排版,另外组件包含onOpen和onAdd事件,请务必给对应的事件绑定对应的交互事件',
    schema: {
      properties: [
        { property: 'id', type: 'string', description: '商品 id' },
        { property: 'title', type: 'string', description: '商品标题', required: true },
        { property: 'price', type: 'number', description: '商品价格', required: true },
        { property: 'image', type: 'string', description: '商品图片 URL' },
        { property: 'description', type: 'string', description: '商品描述' },
        { property: 'tags', type: 'array', description: '标签数组' },
        { property: 'rating', type: 'number', description: '评分,0-5' },
        { property: 'ratingCount', type: 'number', description: '评分人数' },
        { property: 'inStock', type: 'boolean', description: '是否有货' },
        { property: 'badgeText', type: 'string', description: '角标文案' },
        { property: 'onOpen', type: 'function', description: '打开商品详情,必须绑定跳转商品页详情事件' },
        { property: 'onAdd', type: 'function', description: '加入购物车,必须绑定加入购物车事件' },
      ],
    },
    ref: ProductCard,
  },
]

说明:该配置中复用了电商系统已有的 ProductCard.vue 组件,无需额外编写组件代码,仅需明确组件的参数、事件及使用规范,确保大模型能正确渲染组件。

在 AIAssistantDrawer.vue 组件中引入自定义组件,并传入 GenuiChat 组件,实现原生商品卡片的渲染:

import { customComponents } from '../genui/chat/custom-components'
<GenuiChat
    ref="chatRef"
    :url="chatUrl"
    :customFetch="customFetch"
    :customComponents="customComponents"
    :model="model"
    :temperature="temperature"
    :chat-config="chatConfig"
/>

刷新页面后,重新输入测试需求:“我想去露营!但我没经验,也没装备,预算 1500 元以内!”,此时 AI 助手生成的商品卡片将与电商系统原生卡片样式完全一致。

7.png

自定义组件集成完成后,接下来配置对应的交互动作,实现商品加购、详情跳转等核心业务功能,让 AI 助手的交互与电商系统保持一致。

8. 自定义交互:贴合业务场景,原生体验不割裂

为实现商品加购、商品详情跳转、购物车跳转等核心交互功能,我们在 src/genui/chat 目录下新建 custom-actions.ts 文件,定义与电商业务对应的交互动作,并绑定系统原生业务逻辑,确保交互体验与电商系统无缝衔接。

新建 custom-actions.ts 文件,定义 addToCart(加入购物车)、openProduct(打开商品详情)、openCart(打开购物车)三个核心交互动作:

import { z } from 'zod'
import type { ICustomActionItem } from '@opentiny/genui-sdk-vue'
import type { Product } from '../../types'

const ProductActionSchema = z.object({
  id: z.string(),
  title: z.string(),
  price: z.number(),
  image: z.string().optional(),
  description: z.string().optional(),
  tags: z.array(z.string()).optional(),
  rating: z.number().optional(),
  ratingCount: z.number().optional(),
  inStock: z.boolean().optional(),
  badgeText: z.string().optional(),
})

const OpenProductSchema = z.object({
  productId: z.string(),
})

type CreateActionOptions = {
  addProduct: (product: Product) => void
  openProduct: (id: string) => void
  openCart: () => void
}

export function createCustomActions(options: CreateActionOptions) {
  return [
    {
      name: 'addToCart',
      description: '将商品加入购物车',
      parameters: {
        type: 'object',
        properties: {
          product: {
            type: 'object',
            description: '待加入购物车商品',
            properties: {
              id: { type: 'string', description: '商品 id' },
              title: { type: 'string', description: '商品标题' },
              price: { type: 'number', description: '商品价格' },
              image: { type: 'string', description: '商品图片 URL' },
              description: { type: 'string', description: '商品描述' },
              tags: { type: 'array', description: '标签数组' },
              rating: { type: 'number', description: '评分' },
              ratingCount: { type: 'number', description: '评分人数' },
              inStock: { type: 'boolean', description: '是否有货' },
              badgeText: { type: 'string', description: '角标文案' },
            },
            required: ['id', 'title', 'price'],
          },
        },
        required: ['product'],
      } as const,
      execute: (params: unknown) => {
        const parsed = z
          .object({ product: ProductActionSchema })
          .safeParse(params)
        if (!parsed.success) return
        options.addProduct(parsed.data.product as Product)
      },
    },
    {
      name: 'openProduct',
      description: '跳转到商品详情页',
      parameters: {
        type: 'object',
        properties: {
          productId: { type: 'string', description: '商品 id' },
        },
        required: ['productId'],
      } as const,
      execute: (params: unknown) => {
        const parsed = OpenProductSchema.safeParse(params)
        if (!parsed.success) return
        options.openProduct(parsed.data.productId)
      },
    },
    {
      name: 'openCart',
      description: '打开当前用户购物车页面',
      parameters: {
        type: 'object',
        properties: {},
      } as const,
      execute: () => {
        options.openCart()
      },
    },
  ] as ICustomActionItem[]
}

交互动作定义完成后,在 AIAssistantDrawer.vue 组件中注入这些动作,并绑定电商系统原生的业务逻辑(购物车操作、路由跳转),实现交互功能的落地:

import type { Product } from '../types'
import { createCustomActions } from '../genui/chat/custom-actions'
import { useCart, useCartNotice } from '../composables'
import { useRouter } from 'vue-router'

const router = useRouter()
const { addToCart } = useCart()
const { showCartNotice } = useCartNotice()

function onAddProduct(product: Product) {
  addToCart(product, 1)
  showCartNotice(product.title)
}

const customActions = computed(() =>
  createCustomActions({
    addProduct: onAddProduct,
    openProduct: (id) => {
      closeDrawer()
      router.push(`/products/${id}`)
    },
    openCart: () => {
      closeDrawer()
      router.push('/cart')
    },
  }),
)

将自定义交互动作传入 GenuiChat 组件,完成交互绑定:

<GenuiChat
    ref="chatRef"
    :url="chatUrl"
    :model="model"
    :temperature="temperature"
    :custom-fetch="customFetch"
    :custom-components="customComponents"
    :custom-actions="customActions"
    :chat-config="chatConfig"
/>

最后,修改系统提示词,添加自定义组件和自定义交互的约束,确保大模型能正确使用原生组件和交互动作:

你是一个电商导购助手,你的任务是根据用户的需求,推荐商品。禁止使用mock数据,必须使用mcp工具获取商品数据。推荐完商品后,最后加上加入购物按钮,并绑定方法,点击跳转到购物。
你的可展示区域宽度不大,请注意你的布局。商品卡片宽度差不多占满了显示区域,请注意排版,单行只可以放一张商品卡片。
商品卡片需要绑定加入购物车事件和打开商品详情事件,请务必给对应的事件绑定对应的交互事件, 禁止自定义方法,必须使用this.callAction中提到的方法, 例如:this.callAction('addToCart', { product: product })
如果缺少的商品,请提示用户,让用户自行通过其他方式购买。

配置完成后,重新输入测试需求:“我想去露营!但我没经验,也没装备,预算 1500 元以内!”,验证交互功能是否正常。

预期效果: 点击商品卡片的“加入购物车”按钮,会弹出加购成功提示,同时商品会成功添加到购物车;点击商品卡片本身,会关闭 AI 助手抽屉并跳转到对应商品的详情页;点击“打开购物车”按钮,会关闭抽屉并跳转到购物车页面。

8.png

至此,一个完整的电商 AI 导购助手已集成完成。该助手具备商品搜索、原生样式商品展示、核心交互操作等功能,与电商系统的视觉和交互体验完全统一,可为用户提供智能、流畅的导购服务。

结语

从小明的"冲动露营"到一键加购心仪装备,这段旅程的背后,是 GenUI SDK 将 AI 能力与业务系统深度融合的一次完整实践。

回顾整个集成过程,我们只需几个关键步骤:通过 GenuiChat 组件搭建对话界面,用 MCP 工具封装业务查询能力,再以自定义组件和交互动作将 AI 输出与原生系统无缝衔接——整个过程无需重构已有逻辑,改动极其轻量,却换来了质的体验飞跃。

这正是 OpenTiny GenUI SDK 的设计初衷:让每一个业务系统,都能低成本拥有属于自己的智能 AI 助手。

它不只是一个聊天框,而是一套完整的生成式 UI 集成方案

  • 使用简单:提供完整前后端解决方案,支撑快速启动
  • 灵活扩展:集成MCP 工具、自定义组件、自定义交互,三层能力任意组合
  • 风格统一:复用已有组件,AI 生成内容与系统原生界面浑然一体

无论是电商导购、客服助手,还是内部工具的智能化升级,GenUI SDK 都能成为你最顺手的那把钥匙。

如果这篇文章对你有所启发,欢迎访问我们的开源仓库:github.com/opentiny/ge…,点上 Star ⭐ 支持一下——你的每一个 Star,都是我们持续打磨开源产品的动力!

若你想了解更多GenUI SDK, 可以通过以下相关链接进一步体验了解~
官网链接: opentiny.design/genui-sdk
使用文档:docs.opentiny.design/genui-sdk/g…
演练场:playground.opentiny.design/genui-sdk (备用链接: opentiny.github.io/genui-sdk/p…)

AI时代前端摸鱼必备,20秒将psd还原成页面,支持HTML / React / Vue

作者 miaowmiaow
2026年5月7日 11:01

设计稿来了,运营要求"明天上线"。 你打开 PSD,开始切图、量像素、写 CSS、对位置——半天过去了,还在调那个差 2px 的按钮。

这篇文章介绍我们自研的 psd2code 工具:一行命令把 PSD 转成可运行的前端项目,像素级还原 + 智能布局优化 + 多框架产物(HTML / React / Vue)


一、为什么不用现成的 PSD 转 HTML 工具?

社区里其实已经有不少 PSD 转代码方案,但落到运营活动 / H5 / 长图详情页这类「像素级还原」需求上,普遍有三个痛点:

痛点 现象
① 文字字号不准 PSD 里的 FontSize=17.5,浏览器渲染出来又小又模糊——因为忽略了图层 transform.scale
② 全是 position: absolute 几百个图层全部绝对定位,CSS 体积爆炸,后期完全不可维护
③ 组级效果丢失 圆角矩形 8px 外描边、文字描边+投影叠加,要么裁切要么糊掉

我们做了一组对比统计(实际 PSD:南瓜大作战 H5、总决赛-折叠 H5、兑奖 H5):

传统切图工作流:     设计稿到可运行 HTML 平均  4~6 小时
psd2code 自动转换: 设计稿到可运行 HTML 平均  20

由此得出 psd2code 的四大核心方向,也正是本文后续四大章节:

  • PSD 解析:借助 psd-tools,但对它的缺陷做深度修补;
  • 资源提取与优化:像素去重 + 智能命名 + 合成背景图;
  • 布局优化:聚类算法识别行列/网格、智能重写成 Flex;
  • 多 Target 可插拔:同一份 IR,一键产出 HTML / React / Vue 三种工程。

二、整体架构:编译器式分层

psd2code 借鉴编译器的「前端解析 + IR + 后端代码生成」三段式:

flowchart LR
    PSD[".psd<br/>设计稿"] --> Core["core/<br/>PSD 解析 + 图层渲染"]
    Core --> IR[("IR<br/>pydantic 校验<br/>的中间表示")]
    IR --> HTML["targets/html<br/>HTML + CSS"]
    IR --> React["targets/react<br/>Vite + React 18"]
    IR --> Vue["targets/vue<br/>Vite + Vue 3"]
    IR -.预留.-> MP["targets/mini-program"]

    style PSD fill:#f9e79f,stroke:#b9770e
    style IR fill:#aed6f1,stroke:#1f618d
    style HTML fill:#d5f5e3,stroke:#196f3d
    style React fill:#d5f5e3,stroke:#196f3d
    style Vue fill:#d5f5e3,stroke:#196f3d
    style MP fill:#f5f5f5,stroke:#999,stroke-dasharray: 5 5

核心抽象

  1. IR (Intermediate Representation):pydantic BaseModel 严格定义、自带校验。是 coretargets 之间的契约——任何 target 都从 IR 出发,不直接读 PSD
  2. PipelineContext:贯穿所有 Stage 的全局上下文,承载 PSD、IR、配置、产物路径、target 中间产物等。
  3. Stage:单一职责的处理步骤,输入/输出都是 PipelineContext。
  4. Target:一个产物对应一个 Target 子类,通过 @register("html") 注册到全局 registry。

这个分层带来一个直接好处:HTML target 每次能力升级,自动惠及 React / Vue target——因为后两者只是在 HTML 产物之上做二次加工。


三、Skill 使用方式

psd2code 同时也是一个 CodeBuddy Skill,对话里直接说"帮我把这个 PSD 转成 HTML"就会自动触发;也可以脱离 CodeBuddy 单独跑命令行。

3.1 在 CodeBuddy 中调用(推荐)

只要项目里有 .codebuddy/skills/psd2code/ 目录,触发词就能让 CodeBuddy 自动加载该 skill:

"帮我把 设计稿/南瓜大作战.psd 转成 HTML" "把这个 psd 转成 React 项目" "psd 转 vue" "设计稿转代码"

CodeBuddy 会自动选择合适的 target、定位 PSD 文件、执行 skill、把产物路径回报给你。

3.2 命令行直接运行

# 默认 target = html(同时产出 absolute 原版 + Flex 优化版)
python3 .codebuddy/skills/psd2code/psd_to_code.py /path/to/file.psd

# 显式指定 target
python3 .codebuddy/skills/psd2code/psd_to_code.py /path/to/file.psd --target html
python3 .codebuddy/skills/psd2code/psd_to_code.py /path/to/file.psd --target react
python3 .codebuddy/skills/psd2code/psd_to_code.py /path/to/file.psd --target vue

3.3 常用参数

参数 默认 说明
--target {html,react,vue} html 选择产物形态
--css-style {compact,expanded} compact 优化版 CSS 输出风格:compact 接近手写、expanded 全展开 + PSD 坐标溯源注释
--no-css-pretty 关闭 关闭 CSS 美化,回到字母序机械渲染(CI 基线对比常用)

举例:

# 想要排查某个元素位置不对,开 expanded 模式看坐标溯源注释
python3 .codebuddy/skills/psd2code/psd_to_code.py 南瓜大作战.psd --css-style expanded

# 跑 React 产物 + 启动 dev server
python3 .codebuddy/skills/psd2code/psd_to_code.py 南瓜大作战.psd --target react
cd output/南瓜大作战/react
npm install && npm run dev    # http://localhost:5173

# 跑 Vue 产物
python3 .codebuddy/skills/psd2code/psd_to_code.py 南瓜大作战.psd --target vue
cd output/南瓜大作战/vue
npm install && npm run dev    # http://localhost:5173

3.4 产物目录速查

output/<psd_stem>/
├── html/                        # 任何 target 都会先产出
│   ├── index.html               # 原始 absolute 版(保留 dev metadata,方便诊断)
│   ├── index_optimized.html     # Flex 优化版(已剥离 dev metadata,最终交付物)
│   ├── style.css / style_optimized.css
│   ├── main.js                  # 国际化等运行时逻辑
│   ├── metadata.json            # 图层树元数据
│   ├── layer_map.json           # 反查表:CSS 类名 → PSD 原图层名
│   ├── _naming_report.md        # 语义命名报告(每个 token 的来源)
│   └── images/                  # 切图 / 合成图 / 背景图
├── react/                       # --target react 时产出
└── vue/                         # --target vue 时产出

3.5 排查与定位三件套

跑完后如果发现某处不对,优先看这三个文件

  • _naming_report.md:CSS 类名为什么是这个?哪一层(layer1 词典 / layer2 角色推断 / fallback 拼音)给的?
  • layer_map.json.bg-main-4e8c1d 是 PSD 里的哪个图层?图层类型?
  • 对比 index.htmlindex_optimized.html:absolute 原版作为"地面真相",优化版有偏移基本就是 LayoutOptimizer 哪一步过激了。

3.6 系统依赖

Python 3.10+
psd-tools >= 1.14
Pillow >= 10
numpy
beautifulsoup4
pydantic >= 2.0
pypinyin

四、PSD 解析:踩过 psd-tools 的那些坑

psd-tools 是 Python 生态里最成熟的 PSD 解析库,但它只实现了最常用的渲染器,对一些常见场景(shape 填充 + 图层样式、超大发光溢出、引擎字典 transform 等)要么出错、要么丢失。我们的做法是:能修的源码级修,不能修的绕开走手动栅格化

3.1 文字 transform.scale 修正

PSD 文本图层的 engine_dict.StyleRun...FontSize原始字号,但 PS 实际渲染时会用 layer.transform = (a, b, c, d, e, f) 矩阵缩放——其中 a 是 X 缩放、d 是 Y 缩放。

font-size-fix.svg

真实例子:

PSD 图层:"消耗99兑换币"
  raw FontSize    = 17.5
  transform.scale = 1.6
  实际渲染字号     = 17.5 × 1.6 ≈ 28px

如果忽略 transform.scale,浏览器会用 17.5px 渲染,文字直接缩成花生米。另一个易踩的兄弟坑:ParagraphSheet 的路径不在 StyleRun 下,而是 engine.ParagraphRun.RunArray[0].ParagraphSheet.Properties 里的 Justification,老代码写错路径会导致所有文字永远左对齐。这两处我们在 core/psd/text_extractor.py 里重新解析。

3.2 浏览器字宽差异:纵向 + 横向双兜底

PSD 设计师常用思源黑体 / 造字工房,浏览器渲染时却用 PingFang / Arial——相同字号下,浏览器中文会比 PSD 更宽,导致:

  • 单行文本被挤成两行("预测四"+"强")
  • 按钮内文字撑出按钮边界

effect-comparison.svg

我们设计了双兜底:

# 纵向兜底:字号不超过 bbox 高度的 0.85
if font_size >= height * 0.85:
    font_size = height * 0.85

# 横向兜底(仅纯中文短标题):按字数 + 宽度倒推上限
if pure_cjk and char_count <= 12:
    max_font_by_width = width / cjk_count * 0.95
    font_size = min(font_size, max_font_by_width)

效果验证(总决赛-折叠 PSD):

文本 修正前 修正后 效果
主赛区(68px 宽) 25.5px 20.9px ✓ 单行
预测四强(164px 宽) 46.75px 38.5px ✓ 不再折行

3.3 Shape + 图层样式描边:psd-tools 的致命 bug

一个看似普通的 PSD shape 图层(#feffd7 浅黄填充 + 2px 内描边 #5f8618 绿色),psd-tools 直接 layer.composite() 会得到整片纯绿色——填充色完全丢失。

排查后发现两个叠加的根因:

  1. layer.topil() 对 shape 返回 None,老代码降级到 layer.composite() 取基础图;但 composite() 把整块 shape 区域错误地涂成描边色。
  2. draw_stroke_effectskimage.filters.scharr 检测 alpha 边缘做描边,当 alpha 紧贴画布无 padding 时,scharr 检测不到边缘,归一化后 mask 整片=1,描边色铺满整张图。

绕开方案

  • 新增 _render_shape_base_from_fill(layer):跳过 composite(),直接读 SoCo 填充色 + origination 几何(Rectangle / RoundedRectangle / Ellipse)用 PIL ImageDraw 合成基础图。
  • 调用 draw_stroke_effect 前给 alpha 加 padding(pad = max(stroke_size+2, 4)),渲染完再裁回。
# stroke_renderer.py
padded_alpha = np.pad(alpha, ((pad,pad),(pad,pad),(0,0)), mode='constant')
stroke_color, stroke_mask = draw_stroke_effect(bbox, padded_alpha, ...)
out[:,:,:3]  = stroke_color[pad:pad+h, pad:pad+w, :]
out[:,:,3:4] = stroke_mask[pad:pad+h, pad:pad+w, :] * opacity

效果:兑奖活动卡片的浅黄背景 + 2px 绿描边正确还原,无需人工补图。

3.4 图层样式的两层 enabled 开关

PS 图层样式面板有两个开关:

  • 整体开关layer.effects.enabled(fx 行最左边那个勾)
  • 单项开关effect.enabled(外发光/描边/投影各自的勾)

PS 的规则:整体开关关闭 → 所有效果都不渲染,哪怕单项 enabled=True

psd-tools 不替你 AND 这两个标志,psd2code 早期代码多处只看 effect.enabled,导致"PS 中没效果的文本"被当成"有外发光"处理,错误地栅格化成图片。修复后统一用一个 helper:

def is_effect_active(effect, layer):
    if not layer.effects or not layer.effects.enabled:
        return False
    return bool(effect.enabled)

全工程 8 处调用点全部切换。这解决了带 fx 残留的昵称文本全部被错误降级为图片的问题。

3.5 组级效果溢出:手动渲染 + composite 混合

PS 图层效果(描边、阴影、发光,共 8 种)在组(Group)级别有个隐蔽特性:效果会沿组的整体边界裁切

psd-toolscomposite() 能在组内正确复现这一行为——但只在组的 bbox 内有效。一个圆角矩形有 8px 外描边时,描边会溢出组的 bbox 被 composite() 裁掉。实测过各种"绕过"方法——父级 composite、根级 composite、给超大 viewport——都是徒劳:

psd-tools 在任何层级 composite,都按目标节点(及其所有祖先组)的 bbox 做硬裁切,不存在绕过方案

我们的解法是「手动栅格化 + composite 混合」:

hybrid-render.png

1. 先用扩展画布 + 手动逐层渲染 → 拿到完整溢出像素(外部区域)
2. 再用 group.composite(viewport=bbox) → 拿到组 bbox 内的 PS 原生高质量
3. 把 composite 结果覆盖到手动渲染结果的内部区域

最终:外部保留溢出效果,内部达到像素级匹配(实测 Alpha 差异 max=0、mean=0.00)。

硬约束:子组(嵌套组)必须用 sub_grp.composite(viewport=grp_bbox) 渲染,不能退回"递归调用 _render_group_expanded + 裁切"——历史回归实测会在圆角轮廓位置多出 ~75px/行的错误描边。

3.6 总结:PSD 解析的修复清单

问题 表现 我们的做法
transform.scale 未应用 文本被缩成花生米 layer.transform[0],FontSize 乘以它
ParagraphSheet 路径错误 所有文字都是左对齐 ParagraphRun.RunArray[0] 路径重新解析
shape + 图层样式整片涂描边色 兑奖卡片全变绿 手动栅格化填充 + 给 alpha 加 padding 再描边
两层 enabled 未 AND 无效果文本被错误降级为图片 统一 is_effect_active(effect, layer)
组级效果溢出被裁 外描边断掉 混合渲染:手动扩展 + composite 覆盖

五、资源提取与优化

psd2code 对每个图层做一次决策:切图 / 文字保留 / shape 用 CSS 还原 / 吸收为父容器背景 / 合并成单图,最终写到 output/<psd>/html/images/ 目录。

5.1 智能去重:基于内容哈希而非图层名

活动页里"星星""糖果""装饰点"这类小图会被设计师复用几十次,每次都单独切图是极大浪费。

def _save_image_dedup(self, img, name, depth) -> str:
    data = serialize(img)                        # 按 Config.IMAGE_FORMAT 序列化
    md5_hash = hashlib.md5(data).hexdigest()
    if md5_hash in self._image_hash_map:         # 命中去重
        return self._image_hash_map[md5_hash]
    path = make_image_filename(name, content_hash=md5_hash, ltype=ltype)
    self._image_hash_map[md5_hash] = path
    write(path, data)
    return path

南瓜大作战 H5 实测:239 个 image 图层 → 87 张 PNG(去重率 63.6%)。

5.2 语义化文件名:tag + 内容哈希

旧方案拼音 + 自增序号(yuanjiaojuxing_3_7.png)有两个问题:HTML 里不可读;每次运行序号都在跳,git diff 噪声极大。

新方案:

images/<semantic-tag>-<md5前6位>.png

例:rounded-a3f012.png
    btn-receive-279914.png
    bg-main-4e8c1d.png
    candy-big-7b0a12.png
  • semantic-tagsemantic/ 子包从图层名推断得出(支持 3 层置信度:Layer2 角色推断 → Layer1 清洗词典 → fallback 关键词 + 拼音),PS 默认名走 ltype 兜底(img/shape
  • md5前6位 = 图片内容哈希——PSD 没改,文件名就不变,git diff 和 CDN 缓存两全其美
  • 同名撞车自动追加 -2/-3

5.3 形状层保留矢量:不切图就是最好的优化

圆角矩形、椭圆、纯色矩形这类简单 shape,不切图而是直接翻译成 CSS 几何属性

PSD shape 输出 CSS
Rectangle + SoCo 填充 background: <color>; width/height
RoundedRectangle border-radius: <r>px
Ellipse border-radius: 50%
shape + 图层样式描边 border: <w>px solid <color>

效果:CSS 体积下降的同时,文件也能 retina 无损缩放。

5.4 多张全屏背景的合成

活动页常见模式:组里有 2~3 张全屏背景叠加(渐变底 + 花纹 + 噪点)。如果每张都单独切图,HTML 里会写多 url 背景:

.bg-section {
  background-image: url(bg-gradient.png), url(bg-pattern.png), url(bg-noise.png);
  background-position: 0 0, 0 0, 0 0;
  background-repeat: no-repeat, no-repeat, no-repeat;
}

问题:多张 PNG 多次请求,而且浏览器要多次合成。psd2code 的做法是在布局优化阶段检测这种多 url 模式,用 PIL alpha_composite 合成单张 PNG 写回磁盘

输入:bg-gradient.png(284 KB) + bg-pattern.png(412 KB) + bg-noise.png(67 KB)
输出:flat-af0dce35.png (153 KB)   # 1/5 体积、1 次请求

南瓜大作战 H5 实测:47 组合并、节省 45.6 KB。

一个与 CSS 规范相反的坑:background-image: url(a), url(b) 中第一个 url 在视觉最上层,而 PIL alpha_composite 期望"底层在前"。调用方必须 reverse 列表——早期代码漏掉 reverse 导致所有合成图的颜色上下层叠错,颜色"对调"。

5.5 图层扁平化:子图合并 + 父容器吸收

更激进的优化:当一个容器里只有纯 image 子图层(无文本、无按钮),把容器自身的 background + 所有 image 子按 z 序合成单张 PNG、删掉所有子 div 及其 CSS 规则、只留容器自己的 background-image

这个 ImageLayerFlatten transformer 采用后序遍历 + 多轮扫描:最深层先合并、外层再发现"我的子变成单 div 了"继续合并。护栏非常严格:

  • 子元素必须全部 data-type="image" 且无孙子
  • opacity≈1 / mix-blend-mode: normal
  • 容器本身不能有 border-radius / box-shadow / clip-path / filter / transform 等"不能烧进 PNG 的装饰字段"(一旦合并后再叠这些,会双重作用)
  • 总层数 ≥ 2,几何包围盒 ≤ 画布 50%(否则合并一张巨图反而得不偿失)

南瓜大作战 H5 实测:这一步搞定了 47 个容器的视觉简化,DOM 节点从 ~500 降到 ~280。


六、布局优化(本工具最核心的功能)

直接用 absolute 还原 PSD 没问题,但 200+ 图层全部 position: absolute 是工程灾难。psd2codeLayoutOptimizer 把 absolute 智能重写成 Flex,同时保证视觉零偏移。

flex-before-after.png

6.1 七步流水线全景

flowchart TD
    A["原始 absolute HTML"] --> B["Step 1:DOM 重构<br/>(聚类 / 背景剥离 / 容器吸收)"]
    B --> C["Step 1.2:图层扁平化<br/>(多 image 子 → 单张合成 PNG)"]
    C --> D["Step 1.5:同质兄弟分组<br/>(识别 v-list,支持 v-for)"]
    D --> E["Step 2:Flex 推断<br/>(analyzer V10 + 三道闸门)"]
    E --> F["Step 2.5:单子 wrapper 折叠<br/>(消除中间层)"]
    F --> G["Step 3:CSS 去冗余<br/>(z-index 精简 + 等价规则合并)"]
    G --> G2["Step 3.5:重复元素抽取<br/>(3+ hash 类 → 单 base 类)"]
    G2 --> H["Step 4:CSS 美化<br/>(DOM 序 + 属性分段 + 多行)"]
    H --> I["✅ 优化后 HTML / CSS"]

    style A fill:#fadbd8,stroke:#c0392b
    style I fill:#d5f5e3,stroke:#196f3d
    style B fill:#fcf3cf,stroke:#b7950b
    style E fill:#fcf3cf,stroke:#b7950b
    style G fill:#fcf3cf,stroke:#b7950b

6.2 聚类算法:怎么"看懂"一堆 absolute 框

这是整个 LayoutOptimizer 的灵魂。对任意一个容器,我们有 N 个子图层的 bbox(left/top/width/height),目标是自动把它们组织成**行(row)/ 列(col)/ 叠图组(stack)**的树状结构。

第一步:切行(_split_by_rows

从左到右、从上到下遍历子元素,维护一个"当前行"的 envelope(bbox 包络)。新来一个元素 e,判断它和 envelope 的纵向重叠率

overlap_y = min(e.bottom, env.bottom) - max(e.top, env.top)
ratio     = overlap_y / min(e.height, env.height)

if ratio >= 0.5:  # 同行判据
    env 吸收 e,继续
else:
    新开一行

第二步:行内切列

对每一行内部,把切行逻辑换成纵/横轴就是切列。递归后我们得到一棵"行包列 / 列包行"的嵌套树。

第三步:背景层剥离

一个组里常有"全屏卡片底框 + 多个浮层元素"的设计模式。直接聚类会把底框当成一个"占 100% 空间的大元素",严重干扰行/列判断。我们在聚类前先剥离满足以下三种规则之一的"背景层":

  • 完全包含型:bbox 完全包住其他所有元素
  • 主轴覆盖型:在主轴(宽或高)上覆盖 ≥ 90%
  • 双轴主导覆盖型:宽、高都覆盖 envelope ≥ 80%(识别"略带 padding 的卡片底图")

剥离后的背景层被吸收进父容器的 background-image 列表。

第四步:伪多行装饰堆叠回退

切出多行后,若所有行都只有一个元素、且相邻行横向覆盖率 ≥ 80%,回退为 stack(堆叠)——这是"图标 + 标题上下贴边"这种"本质上堆叠装饰"的场景。

第五步:二维网格识别

当多行 × 多列的元素满足"列对齐 + 跨行对齐"时,单纯用"列 包 行"嵌套表达不够干净,改成显式的 v-grid-row + flex column 结构:

rows = _split_into_rows(...)
if len(rows) >= 2 and all_rows_have_aligned_cols(rows):
    layout_type = 'grid'
    flex_applier 包装为:
      父: display:flex; flex-direction:column
      每行: <div class="grid-row-N v-grid-row">

南瓜大作战 H5 的"用户信息区"9 个子图层(剥掉背景卡 + 头像装饰后剩 7 个文本),被正确识别为 2 行 × [4, 3] 列 grid。

6.3 三道安全闸门:什么时候不该用 Flex

不是所有看起来"整齐"的容器都该用 Flex。我们踩过太多坑后总结出三道闸门(全在 layout_analyzer.py):

互相重叠的装饰簇

n 个图层互相重叠(每个与多个邻居都重叠),且 trend_ratio < 0.6。典型场景:多层装饰贴纸、若干徽章叠在一起。判定为堆叠装饰,保持 absolute。

支配背景层

存在某个子元素 X 满足 X.area / envelope.area >= 0.8,且其余子元素中 ≥ 60% 显著落在 X 内(重叠/自身面积 ≥ 0.6)。判定为"大底图 + 多个浮层"的卡片,整组保持 absolute。

装饰剥离

先把子节点分类为 bg / decor / content 三类,只在 content 子集上做趋势检测。这让"内容整齐成行 + 角落有装饰"的容器不再因为装饰打乱排版被误判。

6.4 Flex 应用:非趋势子元素保留 absolute

识别为 vertical / horizontal 后,我们把趋势元素写成 flex 子项(用 margin 表达间距),非趋势元素(角标、装饰)保留其 position: absolute 坐标:

/* 趋势元素:flex 流 */
.prop-card-1 { margin-top: 20px; margin-left: 0; }
.prop-card-2 { margin-top: 18px; }

/* 非趋势元素:保留 absolute */
.badge { position: absolute; right: -6px; top: -6px; }

这里有个极易反复重犯的 bug:容器重构后,子元素的 top/left 是"相对父容器"的坐标(由 extract 阶段产出),不需要再减父 top。

还有一条来自 v-stack 的保护:flex_applier 默认会 del child_css['position'] 把子元素的定位去掉;但如果子本身是 v-stack wrapper(内含 absolute 子节点),删除 position 会让其孩子跳到外层定位,直接飘到屏幕角落。修复:遇到 'v-stack' in child.classes 就改成 position: relative,而不是删除。

6.5 同质兄弟簇检测:识别"同类卡片"

PSD 设计师经常把 N 个商品卡 / 道具卡 / 礼包卡平铺在 #canvas 直接子,没有用父组包起来。传统聚类只在已有 group 内部做,这种列表会全部走 absolute 路径,开发拿到的 HTML 完全看不出"它是一个数据列表",没法直接写 v-for

SiblingGroupDetector 的 5 条 AND 规则:

  1. ≥ 3 个连续兄弟
  2. class 词根相同(去掉 __\d+ 后缀和 -\d+ 序号)——prop__30 / prop-2__38 / prop-10__101 词根都是 prop这是最强的设计师意图信号
  3. bbox 尺寸近似(误差 ≤ 5%)
  4. 满足网格规则:M 列 × K 行 满格排布,同列 left 一致、同行 top 一致(误差 ≤ 2px)
  5. 父容器本身不是 flex

识别成功后包成虚拟容器:

<div class="prop-list v-list" data-virtual="list">
  <div class="prop__30 layer-group">...</div>
  <div class="prop-2__38 layer-group">...</div>
  <div class="prop-3__45 layer-group">...</div>
</div>

CSS 用 display: flex; flex-wrap: wrap; column-gap / row-gap,下游开发可直接写 v-for

一个设计决定:我们不做子结构同构判定。实际 PSD 里同类卡几乎总是有差异(首张卡设计完复制改文案,结构漂移:少一行文字、按钮换成图片、装饰数量不一致)。强求子结构一致会绝大多数现实场景识别失败——class 词根 + bbox 尺寸两条已经够强。

6.6 CSS 去冗余:z-index 精简 + 等价规则合并

core/extract/layer_exporter.py 给每个图层无脑塞 z-index = 全局 layer_id——这是合理的像素还原默认值,但优化版完全不需要。CssDedup 分两个 Pass:

Pass 1 — z-index 精简

遍历每个父容器,收集子元素的 (selector, z) 序列:

形态 动作
长度 0 跳过
长度 1(独 z,其他全 None) 删该 z-index
长度 ≥ 2 严格递增 全删(DOM 顺序 = z 序)
长度 ≥ 2 出现倒挂 全保留

逻辑:position:absolute 子元素的叠序只在"兄弟 bbox 重叠"时依赖 z-index;绝大多数父容器下"DOM 顺序 = z 序升序"(这是 LayerRenderer 的天然产出),浏览器默认行为就能正确实现叠序。

Pass 2 — 等价规则合并

属性 dict 完全相等的多个选择器合并为 .a, .b, .c { ... } 单条规则。南瓜大作战 H5 实测合并 209 条。

Pass 3.5 — 重复元素抽取

Pass 2 合并了 CSS,但 HTML 里依然写了 N 个不同的 hash 类.prop__68 / .prop__105 / ...)。RepeatClassUnifier 进一步:≥ 3 个 .<base>__<digits> 形式的等价类 → 合并为单一 base 类(.prop),HTML 同步改写。

最终 HTML 里你看到的就是:

<div class="prop-list v-list">
  <div class="prop layer-group">...</div>
  <div class="prop layer-group">...</div>
  <div class="prop layer-group">...</div>
</div>

直接就是这种干净的语义化结构。

6.7 实战效果(南瓜大作战 H5)

指标 V2 优化器 当前版本
元素位置偏移 PSD 原位置 94 个元素偏离 5~13px 0 个元素偏离
CSS 行数 4805 1499
CSS 块数 457 ~270
z-index 字段 432 97
6×4 任务网格识别 每个 cell 独立 absolute 自动识别 v-col + v-row 嵌套

下面这张是真实产物里"任务格子"那段——20 多个图层、4 行多列、每个 cell 带描边小图标,全部由算法自动识别:

pumpkin-grid.png

6.8 算法的天花板与人工边界

再好的算法也有上限——下面这些场景 psd2code 会"尽力而为,但结果不一定最优":

① Flex 布局化不充分:设计师图层组织混乱

典型问题:活动页版块 2 的按钮、图标、装饰全部散乱摆在同一个 PSD 根组,没有任何分组——聚类算法能看到的只是 bbox 位置,看不到"设计意图"

👉 解决方案:整理 PSD 图层结构。按视觉版块分组(版块1-签到 / 版块2-道具 / 版块3-任务),每个版块内部再按"标题 / 卡片列表 / 底部按钮"分组。psd2code 会优先在已有组内部做聚类,组边界 = 聚类边界。分好组之后,95% 的场景都能自动重构为干净的 Flex。

② CSS 不够语义化:图层名用了默认命名

典型问题:PSD 图层名是 矢量智能对象图层 12 拷贝 3形状 47——psd2code 只能给你 .img-a3f012 这种内容哈希名,无从推断语义。

👉 解决方案:整理图层命名。重要的结构性图层给中文或 kebab-case 命名(bg-main / btn-领取 / 用户信息背景 / 任务卡片)。psd2code 的 semantic/ 子包能识别:

  • 按钮语义:btn / 按钮 / 领取 / 确定.btn-receive / .btn-ok
  • 背景语义:bg / 背景 / 底框.bg-main
  • 卡片容器:prop / card / 道具.prop-card
  • 中文关键词:通过 common/cn_dict.json 词典映射到 kebab-token

命名整洁之后,HTML 就会是 .prop-card / .btn-receive / .user-info-bg 这种一眼看懂的语义类,而不是 hash 串。

③ 人工干预:特殊场景需人工调整

psd2code 只实现常用渲染器所以部分图层导出效果不好(全实现产出比太低),需要人工干预。

👉 解决方案:手动栅格化或导出图片


七、实战演练:把"南瓜大作战 H5"PSD 跑一遍

南瓜大作战 H5(750 × 6778 长图活动页)为例,一行命令 20 秒拿到完整可运行 HTML:

$ python3 psd_to_code.py "南瓜大作战 H5.psd" --target html

🎨 合并背景图层: ['背景', '矩形 1', '形状 839 拷贝 2']
🖼️  background [合并3层 750x6778] → images/background-f07984.png
📁 solgan (组)
  ✨ 形状 16 (含效果渲染)
  🌟 检测到效果溢出 6px,使用混合渲染策略
📁 版块1 (组) ...
🎨 开始布局优化...
✅ 优化完成!
   - DOM 重构: 60 个
   - v-list 创建: 3 个 (包裹 24 个节点)
   - 应用 flex: 28 个
   - z-index 精简: 304 处
   - CSS 等价规则合并: 节省 128 条
   - 重复元素抽取: 25 组 → 删除 49 个 hash 类、复用到 61 个元素
   - 图层扁平化: 47 个容器 (共合并 105 层, 节省 45.6 KB)
✅ 产物:output/南瓜大作战 H5/html/

浏览器打开 index.html 第一屏——和 PSD 设计稿完全像素对齐,包括 solgan 上的描边发光、用户信息区的圆角、糖果图标的渐变叠加:

pumpkin-hero.png

absolute 原版 vs Flex 优化版对比

pumpkin-compare.png

文件 HTML 大小 CSS 大小 定位方式 可维护性
index.html 71 KB 113 KB 全部 position: absolute ⭐⭐
index_optimized.html 52 KB 38 KB Flex 嵌套 + 局部 absolute ⭐⭐⭐⭐

不要小看这 75 KB 的 CSS 压缩——它代表着 60 个容器被语义化、25 组 hash 类被复用,后期改样式不再需要逐个调 top/left

整个活动页 6778px 长,包含 9 大版块(用户信息 / 任务区 / 道具 / 助力 / 排行 等):

pumpkin-full.png

转换日志里有几个有意思的点:

  • 组级效果溢出自动触发 3 次:solgan 日期组(6px)、副标题组(10px)、糖果数目组(4px)——全部走"手动栅格化 + composite 混合"。
  • 47 个容器被图层扁平化:原本 105 张 image 合并成 47 张 PNG,节省 45.6 KB。
  • 3 组同质兄弟列表识别:道具卡 × 6、任务卡 × 12、排行榜条目 × 6,被包成 v-list——可直接写 v-for
  • 叠图组识别:邀请助力 / 核销助力码 / 版块3(7 个图层)等被 V8/V9 闸门正确识别为"装饰堆叠",保持 absolute。

八、多 Target 可插拔架构

8.1 Target Registry:装饰器注册

targets/registry.py 非常简单:

_REGISTRY: dict[str, Type[Target]] = {}

def register(name: str):
    def _wrap(cls: Type[Target]) -> Type[Target]:
        key = name.strip().lower()
        if key in _REGISTRY and _REGISTRY[key] is not cls:
            raise ValueError(f"Target '{key}' already registered")
        cls.name = key
        _REGISTRY[key] = cls
        return cls
    return _wrap

每个 target 是 Target 子类,实现 build_pipeline(ctx) -> Pipeline

# targets/html/target.py
@register("html")
class HtmlTarget(Target):
    def build_pipeline(self, ctx):
        return Pipeline([
            LoadPsdStage(),
            ParseStage(),
            ExtractAssetsStage(),
            CodegenStage(),
            LayoutOptimizeStage(),
            EmitStage(),
        ])

# targets/react/target.py
@register("react")
class ReactTarget(Target):
    def build_pipeline(self, ctx):
        return Pipeline([
            HtmlTarget().build_pipeline(ctx),   # 先产出 HTML(含优化版)
            Html2ReactStage(),                  # 再转 JSX + Vite 脚手架
        ])

# targets/vue/target.py 同理

CLI --target vueget("vue") → 实例化 → target.run(ctx)。新增 target(比如小程序)只需:

@register("mini-program")
class MiniProgramTarget(Target):
    def build_pipeline(self, ctx):
        return Pipeline([
            HtmlTarget().build_pipeline(ctx),
            Html2WxmlStage(),        # 转 WXML
            Html2WxssStage(),        # 转 WXSS
        ])

无需改动核心代码。

8.2 为什么 React / Vue 都在 HTML 基础上二次加工

业界也有"直接从 IR 生成 JSX"的设计,但 psd2code 选择"先走一遍 HTML target,再转框架":

  • HTML target 的优化(布局、CSS 去冗余、语义化命名)免费继承给 React/Vue——任何一次 LayoutOptimizer 升级自动惠及三端。
  • 开发者本地 review 时可以直接对比 html/index_optimized.htmlreact/src/App.jsx 的视觉一致性,容易定位转换问题。
  • React/Vue 的转换就是 DOM 遍历 + class/style 重映射 + 模板语法替换,逻辑简单、可测试性强。

8.3 产物结构一览

output/<psd_stem>/
├── html/                       # 任何 target 都会先产出
│   ├── index.html              # absolute 版(与 PSD 像素级对齐)
│   ├── index_optimized.html    # Flex 优化版
│   ├── style.css / style_optimized.css
│   ├── main.js                 # 国际化等运行时逻辑
│   ├── metadata.json           # 图层树元数据
│   ├── class_alias_map.json    # 老 hash 类 → 新语义类的映射
│   └── images/                 # 切图 / 合成图 / 背景图
├── react/                      # --target react 产出
│   ├── package.json / vite.config.js
│   └── src/App.jsx, App.css, main.jsx, assets/images/
└── vue/                        # --target vue 产出
    ├── package.json / vite.config.js
    └── src/App.vue, main.js, assets/images/

React / Vue 产物开箱即用:

cd output/<psd_stem>/react   # 或 vue
npm install && npm run dev   # http://localhost:5173

九、其他你可能在意的细节

  • 图片去重:按内容 MD5,同一张装饰图只导出一次。
  • 语义化类名:图层名 预测四强.yucesi__152(拼音兜底),或通过 cn_dict.json 词典映射为 .predict-top4
  • 国际化预留:所有文本节点自动带 data-i18n-key,可通过 JS 动态替换。
  • 旋转/倾斜文本:自动降级为图片,保证视觉一致。
  • 剪贴蒙版:按 layer.clip 标志识别,合并成父图基底 + 描边/发光效果。

十、踩过的坑(写给后来者)

如果你打算自己实现 PSD → 代码工具,以下几个坑可以省你几天:

  1. transform.scale 不能忘——所有 FontSize 都要乘以 transform.a / transform.d
  2. shape + 图层样式描边 psd-tools 会整片涂描边色——必须手动用 SoCo + origination 合成基础图、给 alpha 加 padding 再描边。
  3. 两层 enabled 开关必须 AND——layer.effects.enabled(整体)和 effect.enabled(单项)都要为 True 才算生效。
  4. composite() 的 viewport 限制——任何层级的 composite 都按"目标节点 + 所有祖先"的 bbox 硬裁切,不存在绕过方案,组级溢出效果必须手动扩展画布。
  5. 子组必须用 composite 渲染——不要退回手动递归,会在圆角处多出 ~75px/行的错误描边。
  6. tree.children 顺序 ≠ z 序——背景剥离后再合并 background-image 时,必须按原 DOM sibling index 重排。
  7. 多 url 背景合成时 reverse 列表——CSS 第一个 url 是视觉最上层,但 PIL alpha_composite 期望底层在前,反了会颜色对调。
  8. CSS parser 别用贪婪正则——@media (...) { #canvas { ... } } 嵌套时,简单正则会把内层 #canvas 误当顶层规则,整个 canvas 塌成 0 高。
  9. flex 容器 envelope 越界envelope.left/top 可能为负(图层超出组 bbox),写 padding 时要 max(0, ...),否则 cross_offset 算多了。
  10. v-stack wrapper 的 position 必须保留——flex_applier 默认 del child_css['position'],遇到 v-stack 要改写为 relative,否则内部 absolute 子元素会跳到外层定位。
  11. background-repeat: no-repeat 不是默认值——background-repeat 的 CSS 默认值是 repeat,CssDedup 删默认值时不能把它加进去,否则大背景图会被平铺。

十一、写在最后

psd2code 不是一个"AI 读图猜布局"的玩具——它是一个严格基于 PSD 结构信息的编译器。每一步决策都可解释、可调参、可单测,算法失败点(比如 V8/V9/V10 闸门)都有明确的 fallback 路径。

再强调一次:算法做的再多效果也是有限的。想要 psd2code 产出高质量代码,有两件事你得做:

  1. 整理 PSD 图层结构(按视觉版块分组)
  2. 整理 PSD 图层命名(关键图层给语义名)

做到这两点,运营活动页从设计稿到可上线代码的时间可以从 4~6 小时降到 20 秒。

未来计划:

  • ✅ HTML / React / Vue 已上线
  • 🚧 小程序 target(架构已预留扩展点)
  • 🚧 Tailwind CSS 输出
  • 🚧 Figma 文件支持(共享 IR,新增 figma loader)

如果你也在做活动页 / 长图详情页 / 运营 H5,欢迎试用并提反馈。


Thanks

以上就是本篇文章的全部内容,如有问题欢迎指出,我们一起进步。
如果觉得本篇文章对您有帮助的话请点个赞让更多人看到吧,您的鼓励是我前进的动力。
谢谢~~

源代码地址

Bun 深度调研:一个想把 JavaScript 工具链全部重写的野心项目

作者 王若风
2026年5月6日 22:47

ChatGPT Image 2026年5月6日 22_32_49.png

大家好,我是若风。

2025 年 12 月上旬,Bun 先宣布加入 Anthropic,随后 Anthropic 也发布公告:它收购了 Bun。

我也注意到,Bun 官方在加入 Anthropic 的文章里提到 Claude Code、Factory AI、OpenCode 等 AI 编程工具都在使用 Bun。换句话说,它已经不只是一个“开发者自己试试看”的运行时,也开始进入 AI coding 工具链的内部执行路径。

这 2 件事情叠加引起了我对 Bun 的好奇,说实话,我之前对 Bun 的印象一直停留在“又一个号称比 Node 快的 JS 运行时”这个层面。但这次收购让我觉得事情没那么简单——Anthropic 为什么要收一个 JavaScript 运行时?

带着这个疑问,我花了不少时间做了一次深度调研。越看越觉得,Bun 这个项目比我想象的有意思得多。它不只是“比 Node 快”,它其实在试图重新回答一个问题:现代 JavaScript 工程,到底应该由几层工具组成?

今天这篇文章,就是这次调研的结果。

先说结论

Bun 不是 React、Vue 那种“框架”,而是一个试图把 JavaScript 运行时、包管理器、测试运行器、Bundler、全栈开发服务器 压进同一个二进制里的基础设施产品。

用人话说:Bun 想把 "Node.js + npm/pnpm + Jest/Vitest + esbuild/Vite" 这一大串组合拳,收缩成一个叫 bun 的命令。

这个野心非常大。大到现在社区对 Bun 的态度非常分裂:一边是兴奋,因为确实快;另一边是警惕,因为边界扩得太宽,质量能不能跟上是个问号。

Bun 怎么来的:一次“忍不了”

Bun 的起点不是抽象的“想做一个更快的 JS 运行时”,而是非常具体的工程烦躁。

创建者 Jarred Sumner 在多次访谈和项目回顾里都把 Bun 的起点讲得很工程化:他当年在做一个浏览器里的 voxel game,代码库一大,开发服务器反馈变得难以忍受。InfoWorld 访谈里提到,从保存代码到浏览器看到变化大概要 30 秒;后来的复盘文章里也常把这个痛点描述成几十秒级的等待。不是哲学问题,是会把人耐心一点点磨掉的那种工程问题。

于是他先去折腾 JSX/TypeScript 转译器,后来为了让 Next.js 的服务端渲染跑起来,发现自己又不可避免地走进了 runtime 的坑里。

很多产品的诞生,表面上是“新需求催生新架构”,实际上更像“一个足够能折腾的人,被一个具体痛点逼进了深水区”。Bun 就是后者。

两个关键选择,决定了 Bun 的一切

Bun 在 2021 年做了两个技术选择,几乎锁死了它后来所有的优点和缺点。

选择一:用 Zig,不是 Rust

Jarred 说他一开始也考虑过 Rust,但最后选了 Zig。理由不是“生态更好”,而是更朴素的——他要对底层行为、内存和系统调用路径有足够直接的控制。

Bun 从第一天起就是一个极度强调性能路径的产品。语言选择不是外围决策,它会把团队的心智模型也一起定型。

选择二:JavaScriptCore,不是 V8

这一步影响更大。Node 和 Deno 背后都是 V8,Bun 反而绕到 Safari 那条线,嵌入 WebKit 的 JavaScriptCore。

为什么?因为 JavaScriptCore 的启动速度快。

这个选择给了 Bun 很强的启动速度优势,也让它从第一天就和 Node/Deno 拉开了技术路线差异。但代价也很明显:在 Node 兼容、V8 私有 C++ API、Windows 适配这几个方向,Bun 注定要比“直接站在 V8 上做演化”的路线更辛苦。

也就是说,Bun 后来的很多优点和很多麻烦,其实在 2021 年这两个选择里就已经埋下了。

Bun 的版本演化:从“能跑 demo”到“被 Anthropic 收购”

Bun 到今天大致经历了四个阶段,每个阶段的核心矛盾都不一样。

第一阶段:萌芽期(2021-2022.7)

核心矛盾:如何把对 JavaScript 工具链低效的个人愤怒,变成一个技术上可成立的新产品。

最关键的决策就是 Zig 和 JavaScriptCore。

第二阶段:品牌爆发期(2022.7-2023.9)

2022 年 7 月,Bun v0.1.0 发布后迅速出圈。早期社区买单的不是成熟度,而是三件事:

第一,统一叙事。 Node 时代的 JS 工具栈越来越像乐高——runtime 是 Node,包管理是 npm/yarn/pnpm,转译是 Babel/SWC/tsx,测试是 Jest/Vitest,打包是 webpack/esbuild/rollup/Vite。每多一层,就多一层配置、多一层 cache、多一层边界 bug。Bun 一上来就反着来:别拼了,我给你一个总工具。

第二,速度叙事足够强。 无论是早期官网还是后来的首页,Bun 一直把“快”放在最前面。启动快、HTTP 快、WebSocket 快、bun install 快、bun test 快。

第三,兼容策略更现实。 Deno 早期更像“重写现代 JS 运行环境”的宣言,理念漂亮但迁移成本高。Bun 从一开始就瞄准“Node 的替代者”,不是“Node 的批评者”。它不是要教育开发者换脑子,而是想让开发者带着旧项目直接试。

这件事非常重要。因为在运行时竞争里,迁移成本比理论优雅更接近真实决策。

第三阶段:平台化与兼容攻坚期(2023.9-2025.10)

Bun 1.0(2023.9.8):第一次完成产品定义

Bun 1.0 不只是版本号从 0.x 跨到 1.0。真正重要的是,它第一次把产品定义讲完整了:明确列出想要替代 Node.js、npx、dotenv、nodemon、babel、ts-node、esbuild、webpack、npm、yarn、pnpm、Jest、Vitest。

它讲的不只是性能,而是一个完整的开发路径。

这背后是一种很强的产品哲学:不是让你装更多工具去修 JavaScript,而是让 runtime 本身长出更多“开发者真正高频会用”的器官。

Bun 1.1(2024.4.1):补上 Windows,从明星项目走向平台项目

Windows 支持看起来不性感,但它决定你到底能不能被更大盘子的开发者采用。Bun 1.1 发布文里说,Bun on Windows 已经可以通过 Bun 在 macOS 和 Linux 上自用测试套件的 98%。

这一步的意义在于:你不再只是给愿意折腾的新技术爱好者服务,你开始对更多普通开发者负责。对基础设施产品来说,这是从“项目”走向“平台”的必要一步。

Bun 1.2(2025.1.22):方法论升级

很多人看 Bun 1.2,会先看到新增能力:Bun.s3Bun.sqlbun.lock 文本锁文件。

但我觉得真正的分水岭在于它对 Node.js 兼容性的方法论升级。过去 Bun 修 Node 兼容,更多是哪个 npm 包炸了就去补哪个坑——像打地鼠。Bun 1.2 开始系统性地跑 Node.js 官方测试套件

这意味着 Bun 承认了一件事:如果你真想吃掉 Node 的运行时地位,最重要的战场不是宣传页,而是兼容性回归测试。

这是 Bun 从“产品想象力很强”走向“工程纪律正在成形”的关键一步。

Bun 1.3(2025.10.10):从 runtime 推向 full-stack substrate

官方原话:Bun 1.3 turns Bun into a batteries-included full-stack JavaScript runtime.

新增了内建前端 dev server、内建热重载、可以直接跑 HTML、更强的 Bun.serve() 路由、MySQL 和 Redis 也进来了。

到 1.3 为止,Bun 已经不再只想做“更快的 Node 替代品”。它想做的是:你用一门语言,一套命令,一个二进制,直接把前端开发、后端服务、测试、打包、依赖管理、甚至一部分数据库接入都串起来。

第四阶段:AI 基础设施绑定期(2025.12 至今)

被 Anthropic 收购

2025 年 12 月 2 日,Bun 宣布加入 Anthropic。Jarred 在公告里说得很坦白:截至公告发布时,Bun 收入是 0。

这很真实。Bun 过去默认的商业化设想大概是做某种云托管产品,但 AI coding tools 在 2024 年后突然变成更大的浪潮。Bun 团队判断,把自己放到 Anthropic 体系里,成为 Claude Code、Claude Agent SDK 和未来 AI coding 产品的基础设施层,比继续摸索独立 startup 如何变现更有意思。

这里有一个非常值得注意的转向:Bun 最早是因为前端热重载慢而生,最初服务的是“人类开发者更快写代码”。到了 2025 年底,新叙事变成:AI agents 正在写、测、跑更多代码,所以 runtime 和工具链层的重要性反而更高。

这不是换包装,是战略重心真的变了。

2026 年:继续打磨底层

截至 2026 年 5 月 6 日,我查到的 Bun 最新稳定版是 v1.3.13。这些 1.3.x 小版本说明 Bun 已经进入更像基础设施产品的第二阶段:围绕真实工作负载,打磨测试、安装、内存、兼容这些又难讲故事、又极其影响体感的底层路径。

比如 1.3.13 里:bun test --parallel--isolate--shard--changedbun install 流式解压 tarball,官方称在大仓库里把内存压到之前的 1/17;source map 内存占用最多下降 8 倍;runtime 基线内存再降约 5%。

竞争格局:Bun vs Node vs Deno

Bun 的竞品不是一个,至少有三类对手:Node.js(默认标准)、Deno(方法论型对手)、传统 Node 工具链组合(最真实的日常对手)。

Bun vs Node.js

Node 的价值不是“每一项都最好”,而是“它在无数真实生产环境里被证明足够稳,而且生态默认围着它转”。Node 自己不试图把整个工具链都吃下来,它把 runtime 做成公共地基,然后允许上层生态自由长。

Bun 则更激进。它不满足于做 runtime,它想把“你平时围绕 runtime 装的一整套常用工具”一起卷进来。

Bun 赢在:集成度极高、启动快反馈快(对 CLI 和 AI agent 特别友好)、产品表达更现代。

Bun 输在:稳定性和边缘兼容还没到 Node 那个量级、维护面过宽、生产可预期性仍然弱于 Node。

用户的真实选择逻辑通常是:

  • 选 Bun:新项目、内部工具、CLI、AI agent,迁移包袱小,想少装几层工具
  • 选 Node:老项目依赖深,团队更看重稳定性,生产事故成本远大于日常快一点的收益

Bun vs Deno

两者经常被放在一起,但方法论差很大。

Deno 的路线:先把原则立住,再向现实靠近。secure by default,Web 标准优先,TypeScript 零配置。Deno 2 的现实转向很明显:官方发布文明确提到支持 package.jsonnode_modules、npm 和更强的 Node 兼容。

Bun 的路线:先吃现实,再慢慢补原则。从第一天就强调"drop-in replacement for Node.js",优先考虑的不是权限模型的美感,而是“老项目能不能尽快跑”。

三条路径,三种气质:

  • Node 是默认公路
  • Deno 是重新规划过交通规则的新城
  • Bun 是一条想直接把老城最堵那几段全改成高速路的工程

Bun vs 传统工具链

这才是最真实的竞争。很多团队不会问“要不要从 Node 换到 Bun”,而是问:现有这套 Node + pnpm + Vite + Vitest 的组合,有没有必要整体迁到 Bun?

传统组合的优点是每一层都相对成熟,并且可以替换。缺点是你得自己承担拼装成本——不同工具会反复解析源码、反复做模块图、反复维护 cache。

Bun 赢在减少重复工作、配置面收缩、局部采用路径很丝滑(你可以先只用 bun install)。

Bun 输在传统组合的每个局部都更成熟,专业化工具在极端场景下仍然更强,而且很多成熟团队不喜欢“一个核心工具管太多事”。

Bun 的优势和劣势,都来自同一个源头

Bun 最让人着迷的地方,和最让人不放心的地方,其实是一回事。

它的核心优势来自一个很稳定的价值排序:凡是会拖慢开发者反馈回路的环节,都值得被系统性重做。 安装更快、启动更快、TypeScript/JSX 不用额外转、测试和打包都不绕远路、CLI 分发成本更低。

但正因为不是只做 runtime,而是一路把更多高频能力往 core 里收——包管理器、测试运行器、Bundler、dev server、数据库客户端、对象存储客户端——维护半径不断扩大。任何一个面出问题,都会牵连整个品牌。

GitHub 上能看到非常具体的担忧。有用户在用 Elysia.js + Prisma 的 API 服务里观察到 CPU 持续上升;也有用户把同一套服务从 Node 切到 Bun 后,在 GKE 上内存从 500MB 很快打到 1.2GB 容器上限。这些都是单个 issue 案例,不等于 Bun 在所有生产场景都有同样问题,但它们确实说明:社区对稳定性和边缘兼容的担心并不是凭空来的。

这些声音代表了 Bun 当前最真实的舆论断层:在开发体验层,很多人已经觉得它很香;在关键生产基础设施层,很多人还没有放心。

未来会怎样?

我觉得有三种剧本。

最可能:外围切入,向中心渗透

Bun 会继续成为 AI-native JavaScript 工具链和 CLI 分发的重要基础设施。很多团队会先用 bun installbun test、可执行程序编译、内部脚本,再慢慢决定要不要让核心服务也切到 Bun。

最危险:功能扩张但质量跟不上

功能继续高速扩张,但质量治理跟不上,导致 Bun 在生产后端场景里长期背着“不够稳”的标签。最后更多停留在“很好用的开发工具/CLI 基座”,而不是全面替代 Node 的基础设施。

最乐观:AI 时代的默认 JS 执行层

Anthropic 的资源、AI coding tool 的真实需求、Bun 团队对性能和 DX 的持续偏执,最终叠加成一个结果:Bun 在 2 到 3 年里把 Node 兼容和稳定性再往前推一个台阶,成为 AI 时代默认的 JavaScript 执行与分发层。

写在最后

回头看看 Bun 的发展历程,有一个感受很深。

Node 代表的是“runtime 做小,生态做大”。Deno 代表的是“先把原则立住,再兼容现实”。Bun 代表的是“把最影响效率的几层工具重新焊成一个整体,并且优先服务真实迁移路径和更短的反馈回路”。

所以 Bun 最核心的竞争力,从来不只是快。而是它在试图把“现代 JavaScript 工程到底应该由几层工具组成”这个问题,重新回答一遍。

这就是它值得被认真研究的地方。也是它注定会继续伴随争议的原因。

对于普通开发者,我的建议是:

如果你在做新项目、CLI 工具、内部工具、AI agent 相关的东西,Bun 值得认真试试。你可以先只用 bun installbun test,先吃到局部收益,不需要一步到位。

如果你在维护大型生产系统,继续用 Node 是更安全的选择。Bun 还没到可以放心把核心服务压上去的阶段。

但不管你用不用 Bun,它正在改变 JavaScript 工具链的游戏规则。这件事本身就很值得关注。

参考来源

📱 TRAE SOLO 移动端上线征文|“我的第一次移动端AI办公” 评测,赢机械键盘礼包+10w矿石!

作者 掘金酱
2026年5月6日 18:11

web-首页-侧边栏 260_120px.png

参与指南:下载 TRAE SOLO 移动端,完成一次500字文章评测分享,并带话题 #TRAE移动生产力# 发布,就有机会赢机械键盘、露营车、榨汁机和 10w 矿石!

近日 TRAE SOLO 移动端正式上线了,不少朋友在问:这个「移动端」到底是啥?真的能手机写代码、远程跑脚本吗?

我上手试了下,发现它确实解决了一个真实痛点——你总有一些「人在外面,但电脑在工位/家里」的时刻:

  • 路上突然想到一个 Bug 解法,没法立刻录入代码上下文
  • 下班后收到线上告警,身边没电脑,只能干着急
  • 出差途中急需修改文件或跑个脚本,电脑不在身边…

TRAE SOLO 移动端就是来堵这些痛点的。

它主打三大核心场景:

  • 想法随时下发:语音/拍照触发 AI,自动补全上下文并起草文档
  • 无缝延续任务:手机接力 PC 任务,云端保持运行,工作不断流
  • 远程操控电脑:手机调用 PC 算力与文件系统,一句话跑脚本、查数据库

这一次,我们邀请大家一起上手体验,写下你的「第一次移动端 AI 办公」真实评测!

✨活动时间和主题

  • 活动主题:TRAE SOLO 移动端上线 —— 「我的第一次移动端 AI 办公」评测征文
  • 话题话题#TRAE移动生产力#
  • 征文时间:2026年5月6日 – 5月30日
  • 结果公示:6月8日 – 6月12日

⁉️如何参与

发布文章时选择带话题 #TRAE移动生产力# 就可以被统计到哦💁🏻

image.png

✍️ 写什么?三个方向任选

围绕 TRAE SOLO 移动端的核心功能,任意选择一个方向完成体验并写成文章:

方向 具体操作
🎤 语音下发任务 打开 TRAE 移动端,用语音说“帮我写一个 xxx”,让 AI 生成结果,截图保存回复
💻 远程操控电脑 在手机上远程让电脑执行一个简单命令,如“列出桌面文件”,截图保存执行结果
🔄 无缝接力任务 先在电脑端开始任务(如写一段代码),手机上继续同一对话,截图保存接力结果

✅ 文章必须带话题 #TRAE移动生产力# ,且 必须获得官方推荐 才能参与评奖。

🗒️赛道激励

本次征文分 常规挑战进阶挑战 两个赛道,参与活动的文章必须获得官方推荐,才能参与奖项评选:

常规挑战

  • 要求:正文字数不少于 500 字,文章内容至少包含 1 张真实截图/GIF

  • 评选:按照评选规则,根据文章质量+热度综合评分

  • 奖励:阳光普照奖

进阶挑战

  • 要求:正文字数不少于 700 字,文章内容至少包含 2 张真实截图/GIF

  • 评选:按照评选规则,根据文章质量+热度综合评分

  • 奖励:根据最终得分,获取一等奖、二等奖、三等奖、人气奖、深度参与奖

奖项激励

⚠️ 特别注意:常规挑战和进阶挑战奖品 可叠加!但每位参赛者只能获得 1 次单一赛道相关奖项。

奖项 名额 奖励
一等奖 1 MageGee 机械风暴套装 真机械键盘鼠标套装+移动端优秀体验官奖杯
二等奖 2 户外折叠便携露营车
三等奖 5 多功能榨汁机
人气奖 10 便携一壶三杯茶具套装
深度参与奖 进阶挑战其余有效作者 10w掘金矿石 -
阳光普照奖 前90名 鼠标垫小号26*21cm

☝️征文要求

本次征文活动需要符合主题,对于文章类型以及活动参与方式,我们有以下几点小要求。

  • 在掘金文章区带话题 #TRAE移动生产力# 发布

  • 内容:使用 TRAE SOLO 移动端完成任意一个功能的真实记录

  • 字数 ≥ 500 字,不得有广告/洗稿/凑字数等行为,如果发现有此类行为,直接取消资所有获奖资格(备注:文章中50%及以上内容与网络内容相同,即视为洗稿);

  • 必须包含至少 1 张真实使用截图(手机界面或电脑联动界面)

  • 原创首发,不得抄袭,已在其他平台发布过的文章不计入活动;

  • 刷赞、刷量等有作弊行为的文章,直接取消比赛资格,不能参与评选;

  • 文章不能只贴代码,可以讲清楚这个代码在解决什么问题,解决思路是什么,如代码量超 60%则不计入;

🧐评选标准

  • 内容质量(60%) :由稀土掘金内部评审团从实用性、创新性、结构完整性进行打分;

  • 社区热度(40%) :根据文章互动数 + 阅读数综合计算,个人数据不计入

📱 额外福利:站外分享积分换豪礼

将自己发布的评测文章转发到 知乎 / 即刻 / 小红书 / 微信公众号 ,提交截图+链接到指定问卷,可累计积分兑换礼品。

转发登记表: TRAE SOLO 移动端上线征文活动-站外转载登记表

所需积分 奖品 名额
5 积分 手持小风扇 20 人
15 积分 陶瓷碗四件套 10 人
30 积分 卡通萌宠加湿器 5 人

积分规则

  • 每转发一个平台 → +1 积分

  • 获得 ≥20 点赞 或 ≥5 评论 → +2 积分(个人数据不计入)

  • 同一篇文章可转发多个平台,积分可累计

⚠️ 每人限兑换一次,不可重复兑换,先到先得。

🤵征文对象

稀土掘金社区全体掘友

⏰活动时间

2026年5月6日——2026年5月30日23:59

💡注意事项

2026年5月30日活动结束后,约10-15个工作日通过系统消息的形式公示获奖情况,预计填写问卷后的20个工作日内完成奖品发放。

移动端 AI 办公到底能多丝滑?TRAE SOLO 能不能真正让你 “随时随地调起电脑生产力”

答案就在你的第一次体验里。

拿起手机,录个屏、截个图,写下你的真实感受。奖品只是锦上添花,你的每一篇评测,都会帮助更多开发者拥抱移动办公的新可能。

📲 立即下载 TRAE SOLO 移动端,写下你的第一篇移动端 AI 办公评测!

☎️ 加群交流

参加活动的掘友一定要入群哦,重要消息不错过,大家互相鼓励,有问题也可以在群内咨询哦~

contact_me_qr.png

❗注意:活动开启,以及发奖进程都会第一时间群内通知,一定要进群呀,避免错过消息


注:最终解释权归稀土掘金技术社区 官方所有

用时7天,花费30元,我vibe coding这个网站

作者 Hooray
2026年5月5日 14:54

动机

我有一款 2 年没更新的产品 One-step-admin ,开发初衷是为了更高效的进行跨业务模块操作,但当时设计的交互方式如今回看并不理想。

四月初的时候,我突然有了一个新的交互思路,但那会更多精力集中在发布 Fantastic-admin v6.0 这个全新版本上。但是我让 gemini 先产出了一份原型,验证了一下交互方案的可行性。

原型

相信已经有人看出端倪了,是的,交互这块参考了 MacOS 上的台前调度。

执行

直到五一节前,我准备开始开发这款产品,并且我希望体验一下完全 vibe coding 是什么感觉。

我暂定名称为 One-step Console,中文叫一步控制台,它算是 One-step-admin 的迭代产品,因为目标其实是一致的,还是为了更高效的进行跨业务模块操作。

目前产品还没有对外发布,我先截取了一些图片,方便你了解产品的核心交互设计。

切换舞台

所有功能模块视为一个独立的窗口,每个舞台默认承载一个窗口,所以可以通过切换舞台来实现快速切换窗口。

这样设计的优势在于,传统的网站一个功能模块就是一个独立的路由,如果需要在多个模块间操作,就需要频繁跳转路由,在这前提下,开发中还需要考虑页面保活(KeepAlive)。但现在只需要切换激活舞台即可。

每个舞台支持多窗口布局

通过拖拽窗口到指定舞台,可以实现多窗口的舞台,每个舞台最多支持 4 个窗口,舞台内的窗口可以进行排序和布局的调整。

收藏舞台

可以将多窗口的舞台添加到收藏,方便下次一键打开预设舞台。

拖拽预览

未激活的舞台会缩小尺寸并停靠在侧边栏上,除了可以点击切换舞台外,也支持通过拖拽进行预览。

侧边栏定位和宽度

可以根据使用习惯,调整侧边栏的位置和宽度,并且也支持控制侧边栏显示舞台数。并且超出数量限制的舞台,并不会直接销毁,所以不用担心再次唤起舞台时,窗口会被重新渲染,这部分设计遵循了 LRU (Least Recently Used) 缓存淘汰算法。

最后

以上就是我用了 7 天时间,总计花费了 30 元,几乎纯靠 vibe coding 开发的一款产品,接下来我会完善后续工作,尽快将它发布出来。

立正请站好:一个组件复用 Skill 的工程化实践|得物技术

作者 得物技术
2026年4月14日 09:53

一、背景:为什么要做这个 Skill

做这个 Skill 的初衷很直接,也很现实:功能开发时容易"顺手新建一个",而不是先复用已有组件,造成组件库越来越臃肿。这件事对团队的伤害其实是复利型的:

  • 重复组件越来越多;
  • 维护成本越来越高;
  • UI/交互一致性越来越差;
  • AI 生成代码时也更容易继续复制混乱。

所以做这个 Skill 的目标不是"帮 AI 搜索一下",而是:把"复用优先"的思考过程流程化,让 AI 在写代码前先走一遍"查索引 → 判断是否复用 → 命不中再新建"的路径。

二、想解决的不是搜索问题,而是“思考顺序”问题

一开始很容易把问题理解成:"做个组件搜索工具给 AI 用就好了"。但实际落地后发现,真正的问题不是工具有没有,而是:

  • AI 会不会主动用;
  • AI 什么时候用;
  • AI 用完之后是否还能回到项目上下文;
  • AI 能不能稳定走同一条流程。

这和 Vercel 在他们的 agent 评测里观察到的现象很像:skills 本身不是没用,而是 agent 往往不会稳定触发;而把基础知识放进 AGENTS.md 这种"被动上下文"后,稳定性反而更高。Vercel 的实验里,默认 skill 触发并没有提升通过率,加入显式指令后才明显改善,而 AGENTS.md 文档索引方案表现更稳定。这给了我一个很关键的设计方向:先解决 AI 的"决策点"问题,再解决 AI 的"能力"问题。

三、核心设计思路:AGENTS.md + Hook + Skill(三层结构)

最终采用的是三层结构:

AGENTS.md:放基础上下文(常驻)

把"组件复用优先"的规则、组件索引入口、扫描后需要做的事情,放进 AGENTS.md(或同类常驻上下文机制)里。目的不是塞满文档,而是让 AI 每轮都知道:

  • 这个仓库有组件复用机制;
  • 默认应该先查可复用组件;
  • 查不到再考虑新建;
  • 扫描后还有描述补全流程需要继续执行。

这层解决的是:AI 根本不知道你有这套机制。不写进去,AI 主动使用 skill 的概率确实会很低(这点我踩过坑)。

Hook:做路由增强(提高触发概率)

如果运行环境支持 hooks(例如 Claude Code 的 UserPromptSubmit 支持在用户 prompt 处理前注入额外上下文),就可以做一层"意图路由增强":在用户提到"组件复用 / 是否有现成组件 / 封装组件 / 查组件"等语义时,给 AI 注入提示,让它优先走组件复用流程。Claude 的文档明确写了 UserPromptSubmit 会在处理前触发,并且可通过 additionalContext 注入上下文。这层解决的是:AI 知道有 skill,但不一定想起来用。

Skill:提供流程和工具(真正执行)

Skill 不是只写说明文档,而是要提供:

  • 明确的调用入口;
  • 稳定的输出格式;
  • 可执行脚本;
  • 失败时的兜底逻辑。

OpenAI 的 Codex Skills 文档里提到 skills 是"渐进披露"机制:运行时先看到 skill 的元信息(尤其是 description),只有决定使用时才加载完整 SKILL.md;而且隐式触发高度依赖 description。这也是为什么 skill 的触发边界和描述要写得非常清楚。这层解决的是:AI 想用了,但执行过程不稳定。

四、这套 Skill 在源码里是怎么落地的(我的实现)

下面是我这次组件复用 Skill 的几个关键实现点:

先把"入口"收敛成一个:find-component.js

我在 SKILL.md 里明确规定:Agent 必须调用统一入口find-component.js。这样做的原因很简单:

  • 避免 AI 在多个脚本之间犹豫(scan-components、match-component、resolve-scope……);
  • 避免 AI 漏掉前置步骤(比如索引不存在时先扫描);
  • 避免 AI 调用路径不一致导致结果不稳定。

统一入口做了几件事(都在 find-component.js 里):接收查询词(query)、仓库根路径(repoRoot)、当前聚焦路径(startDir)。

  • 如果 components.csv 缺失,内部自动触发run-scan.js;
  • 调用 resolve-scope 计算当前应用和允许搜索范围;
  • 调用 match-component 做匹配排序;
  • 命中时记录使用(用于后续加权);
  • 按固定 JSON 协议返回结果(成功/失败/无匹配/是否触发扫描等)。

这一步本质上是把分散逻辑聚合成"一个业务动作":"查一下有没有可复用组件",而不是"先算 scope,再查 CSV,再排序,再补扫,再记 usage"。这对 AI 很关键。

不是"全仓库乱搜",而是"当前应用 + 根级共享"优先

在 monorepo 场景里,组件复用很容易踩两个坑:

  • 只搜当前 app,漏掉根级共享组件;
  • 全仓乱搜,结果太多太噪音。

所以我在 resolve-scope.js 里做了一个比较工程化的范围解析策略:

  • 读取 pnpm-workspace.yaml 解析 workspace 包;
  • 根据当前聚焦文件/目录反推 currentAppRoot;
  • 再结合 root_scope_patterns(例如 apps/_share/、packages/ 等)构建允许范围;
  • 最终形成一个搜索集合:当前应用 + 根作用域共享包。

如果没有聚焦子项目(比如 startDir 就是 repo root),则切换为全量 scope。这个设计很像人类工程师的查找策略:先看"我这个业务应用里有没有",再看"全局共享有没有",而不是直接在整个 monorepo 海里捞针。

匹配不是纯关键字:我做了"多因素加权"

组件匹配如果只做字符串包含,很快就会变成垃圾召回器。我在 match-component.js + fuzzy-match.js 里做了一个组合评分,核心包括:

  • 名称精确/包含匹配;
  • 模糊匹配(编辑距离);
  • Token 重叠;
  • 首字母缩写匹配(例如 dlp 匹配 DateLinkPicker);
  • 当前应用加权(当前 app 的组件优先);
  • 使用频率加权(常用组件更靠前);
  • 来源质量加权(README 推断质量高于纯 inferred);
  • 存在性校验(文件不存在则降权/过滤);
  • 记录类型权重(组件优先于依赖)。

这一步的目标不是追求"算法先进",而是让排序更符合团队真实使用习惯:"更可能被复用的组件排在前面"。此外我还加了一个低分阈值(NO_MATCH_SCORE_THRESHOLD):

  • 如果最高分太低,就认为是噪音命中;
  • 可以触发一次扫描后再查;
  • 还是低分则按"无匹配"返回,不把噪音结果塞给 AI。

这个点很重要,因为 AI 一旦拿到一些低质量候选,很容易"将错就错"。

把"索引构建"做成可复用流水线,而不是一次性脚本

很多类似方案停在“扫一遍生成 CSV”,然后就过时了。我这次把扫描做成了 run-scan.js -> index-manager -> enrich 的流水线,核心考虑是持续维护:

run-scan.js 负责编排流程

  • resolve-scope;
  • updateIndex;
  • 自动触发 autoEnrich(可配置)。

index-manager.js 负责索引更新策略

  • 保留历史记录并合并;
  • 根据 source_hash 跳过未变化组件;
  • 记录 last-scan-changed-ids.json;
  • 支持并行扫描(包数量较多时启用);
  • 对缺失文件支持标记 exists=0(在查找阶段也会回写)。

扫描后进入 Agent 富化(enrich)流程

  • 读取 agent-enrich-prompts.json;
  • 找出 summary 占位符项;
  • 按 id 回到 components.csv;
  • 读取源码/README;
  • 生成 summary + keywords;
  • 再通过 update-component-summary.js 写回。

更关键的是在配置里启用了:

  • agent_mode_no_fallback = true。

也就是说,在 Agent 模式下不走规则引擎降级,而是要求 Agent 必须完成这一步。这其实就是"流程化思考"的精髓:不是建议,而是纳入主流程。

让 Skill 不只是"搜索器",还是"反馈回路"

一个很容易被忽视的点是:查找命中后,我还记录了使用行为(usage-tracker)。这意味着系统不是静态的,它会逐步学习团队偏好:

  • 哪些组件经常被复用;
  • 哪些组件在某个 app 里更常出现;
  • 哪些结果应该在排序中更靠前。

这是一种很轻量但非常实用的反馈机制——不需要搞复杂训练,也能提升 AI 下一次推荐质量。

五、这次实现里,总结出"让 AI 流程化"的 3 条原则

这也是我最想分享的部分:

原则 1:把基础上下文放进 AGENTS.md(或用 Hook 注入)

如果不这样做,AI 主动使用 skill 的概率很低。原因不是 AI 笨,而是 agent 的执行是有"决策成本"的:

  • 它要先意识到有 skill;
  • 再判断该不该用;
  • 再决定什么时候用。

而把基础上下文放进 AGENTS.md 或通过 hook 提前注入,本质上是在减少决策点。Vercel 的评测结果说明了这种"被动上下文"在某些场景下会更稳定。

原则 2:Skill 需要直接提供工具函数给 AI 调

只写一堆说明文档不够。AI 在工程任务里最需要的是:

  • 一个可以直接执行的入口;
  • 明确的参数;
  • 稳定的返回结构。

所以我把 find-component.js 做成统一入口,并定义了固定 JSON 输出(ok / matches / noMatch / scanTriggered / hint / error 等),这会明显提升 AI 的执行稳定性。

原则 3:显式告诉 AI 调哪些函数,并把分散逻辑聚合到一个入口

这是最容易被忽略、也是最影响稳定性的一点。如果给 AI 暴露一堆脚本:

  • resolve-scope.js;
  • match-component.js;
  • run-scan.js;
  • scan-components.js;
  • index-manager.js。

它理论上能拼起来,但实践里很容易漏步骤、顺序错、参数错。所以我在 Skill 里显式规定:

  • 查找时用 find-component.js;
  • 构建时用 run-scan.js;
  • 更新描述时用 update-component-summary.js。

把复杂系统收敛成几个明确入口,AI 才容易稳定执行。

六、这次实践里一个很重要的认知转变

我原来以为"写 skill"是在给 AI 增加能力。现在更像是在做:给 AI 增加"默认工作方式"。换句话说,skill 不只是能力包(capability bundle),也是流程控制器(workflow controller)。

  • AGENTS.md 负责"告诉 AI 世界观";
  • Hook 负责"提醒 AI 现在该用哪套流程";
  • Skill 负责"把动作做完,并且做得稳定";
  • 日志/CSV/usage 负责"让系统可观测、可迭代"。

这套思路不只适用于组件复用,后面也可以迁移到:

  • 任务优化闭环;
  • 日志分析标准化;
  • 策略诊断流程;
  • 代码规范治理。

七、这套方案当前的价值

  • AI 开发前先查可复用组件,而不是直接新建;
  • monorepo 下按"当前应用 + 共享组件"范围检索;
  • 索引缺失自动扫描;
  • 组件描述富化进入主流程;
  • 匹配质量有加权与反馈回路;
  • 整体流程有明确入口和输出协议。

八、结语:让 AI 少一点"即兴发挥",多一点"工程纪律"

这次组件复用 Skill 的开发过程,对我最大的启发不是"AI 能帮我写多少代码",而是:AI 其实非常适合被放进一套清晰流程里工作。只要把下面三件事做好:

  • 基础上下文(AGENTS.md / hooks);
  • 可执行入口(工具函数);
  • 明确流程边界(统一入口 + 输出协议)。

AI 就不会只是"一个会说话的代码补全器",而会更像一个遵守团队规范的工程协作者。而这,才是我做这个 Skill 真正想要的结果。

引用文档: vercel.com/blog/agents…

往期回顾

1.财务数仓 Claude AI Coding 应用实战|得物技术

2.日志诊断 Skill:用 AI + MCP 一键解决BUG|得物技术

3.Redis 自动化运维最佳实践|得物技术

4.Claude在得物App数仓的深度集成与效能演进

5.Claude Code + OpenSpec 正在加速 AICoding 落地:从模型博弈到工程化的范式转移|得物技术

文 /魏无涯

关注得物技术,每周更新技术干货

要是觉得文章对你有帮助的话,欢迎评论转发点赞~

未经得物技术许可严禁转载,否则依法追究法律责任。

❌
❌