普通视图

发现新文章,点击刷新页面。
昨天以前掘金 前端

连载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 开发的一款产品,接下来我会完善后续工作,尽快将它发布出来。

❌
❌