我在设计工具里实现了一个 Agent Team:多智能体协作生成 UI 的实战经验

15 阅读8分钟

前言

OpenPencil(一个开源矢量设计工具)的 AI 设计生成功能中,我实现了一套多智能体编排系统(Agent Team)。用户输入一句话描述,系统会自动将其拆解为多个子任务,分配给不同的 Sub-Agent 并行生成,最终在画布上实时呈现完整的 UI 设计。

本文将从架构设计、核心实现、并发控制、流式渲染等角度,详细拆解这套系统的实现过程。

一、为什么需要 Agent Team?

单 Agent 的瓶颈

最初的设计生成是"一锅端":把整个 UI 描述扔给一个 LLM,让它一次性输出所有节点。问题很快暴露:

  1. 上下文爆炸:一个完整的 Landing Page 可能包含 100+ 节点,LLM 在后半段容易"遗忘"前面的设计约束
  2. 速度太慢:复杂页面需要 30-60 秒才能完成,用户盯着空白画布等待
  3. 错误传播:一个区域出错(比如 Hero 排版溢出),后续所有区域都会受到影响
  4. 无法并行:单次 API 调用是串行的,无法利用多路并发加速

Agent Team 的思路

借鉴软件工程中的分治策略:

graph TD
    O["Orchestrator<br/>(快速规划 Agent)"]
    O -->|OrchestratorPlan| A1["Agent #1 &quot;Kiki&quot;<br/>导航栏"]
    O -->|OrchestratorPlan| A2["Agent #2 &quot;Mochi&quot;<br/>Hero 区"]
    O -->|OrchestratorPlan| A3["Agent #3 &quot;Pixel&quot;<br/>特性区"]
    A1 -->|JSONL| Canvas["实时画布渲染 + 动画"]
    A2 -->|JSONL| Canvas
    A3 -->|JSONL| Canvas

每个 Agent 只负责一个空间区域,互不干扰,独立生成,最终合并到同一个画布。

二、五阶段流水线架构

整个 Agent Team 的执行分为 5 个阶段:

// orchestrator.ts - 主入口
export async function executeOrchestration(
  request: AIDesignRequest,
  callbacks?: {
    onApplyPartial?: (count: number) => void  // 节点插入回调
    onTextUpdate?: (text: string) => void     // 进度更新回调
    animated?: boolean                         // 是否启用动画
  },
  abortSignal?: AbortSignal,
): Promise<{ nodes: PenNode[]; rawResponse: string }>

Phase 1:Planning — 编排 Agent 做任务分解

第一阶段用一个轻量级的 Orchestrator Agent 将用户的设计描述拆解为多个空间子任务。这个 Agent 不做任何实际的设计生成,只做"分活"。

const plan = await callOrchestrator(
  preparedPrompt.orchestratorPrompt,
  preparedPrompt.originalLength,
  request.model,
  request.provider,
  (thinking) => renderPlanningStatus(thinking), // 实时展示思考过程
  abortSignal,
)

Orchestrator 的 system prompt 经过精心设计,只做一件事——结构化分解:

export const ORCHESTRATOR_PROMPT = `Split a UI request into cohesive subtasks.
Each subtask = a meaningful UI section or component group.
Output ONLY JSON, start with {.

DESIGN TYPE DETECTION:
1. Multi-section page → width=1200, height=0, 6-10 subtasks
2. Single-task screen → width=375, height=812, 1-5 subtasks
3. Data-rich workspace → width=1200, height=0, 2-5 subtasks
...`

输出是一个严格的 JSON 结构:

{
  "rootFrame": {
    "id": "page",
    "name": "Landing Page",
    "width": 1200,
    "height": 0,
    "layout": "vertical"
  },
  "styleGuide": {
    "palette": {
      "background": "#F8FAFC",
      "accent": "#6366F1",
      "text": "#0F172A"
    },
    "fonts": { "heading": "Space Grotesk", "body": "Inter" },
    "aesthetic": "clean modern with purple accents"
  },
  "subtasks": [
    {
      "id": "nav",
      "label": "Navigation Bar",
      "elements": "logo, nav links, sign-in button, CTA button",
      "region": { "width": 1200, "height": 72 }
    },
    {
      "id": "hero",
      "label": "Hero Section",
      "elements": "headline, subtitle, CTA, illustration",
      "region": { "width": 1200, "height": 560 }
    }
  ]
}

这里有几个关键的设计决策:

1. 元素边界(Element Boundaries)

每个子任务必须声明自己负责哪些 UI 元素,且元素不能跨子任务重叠。这是防止"两个 Agent 同时生成了提交按钮"这类冲突的关键:

subtask "Login Form":  elements = "email input, password input, submit button"
subtask "Social Login": elements = "Google button, Apple button, divider"
                                    ↑ 不会重复生成 submit button

2. 统一的 Style Guide

Orchestrator 生成一份全局色板和字体方案,所有 Sub-Agent 共享这份 Style Guide。这保证了即使多个 Agent 并行工作,最终产出的颜色和风格也是一致的。

3. 空间区域(Region)

每个子任务带有目标区域尺寸(宽高),Sub-Agent 据此控制内容量,不会生成过多或过少的节点。

Phase 2:Canvas Setup — 创建画布骨架

根据 Plan 在画布上创建根容器。对于并发模式(多屏设计),系统会为每组屏幕创建独立的根 Frame:

if (effectiveConcurrency > 1) {
  // 并发模式:每个 screen group 一个根 Frame
  for (let g = 0; g < screenGroups.length; g++) {
    const rootNode: FrameNode = {
      id: `${plan.rootFrame.id}-${group.screen}`,
      type: 'frame',
      name: frameName,
      x: nextX,  // 横向排列
      y: 0,
      width: plan.rootFrame.width,
      height: frameHeight,
      layout: 'vertical',
      children: [],
    }
    // 第一个 frame 走 insertStreamingNode 处理空画布替换
    // 后续 frame 直接 addNode,避免 ID 重映射冲突
    if (g === 0) {
      insertStreamingNode(rootNode, null)
    } else {
      addNode(null, rootNode)
    }
    nextX += plan.rootFrame.width + 100  // 屏幕间距 100px
  }
} else {
  // 顺序模式:所有子任务共用一个根 Frame
  const rootNode: FrameNode = { ... }
  insertStreamingNode(rootNode, null)
}

Phase 3:Sub-Agent 执行 — 核心生成阶段

这是最复杂也最精彩的部分。每个子任务会被分配给一个 Sub-Agent,以 JSONL 流式格式输出 PenNode 节点。

ID 命名空间隔离

这是并发安全的核心机制。每个 Sub-Agent 的所有节点 ID 必须带上所属子任务的前缀:

export function ensureIdPrefix(node: PenNode, prefix: string): void {
  if (!node.id.startsWith(`${prefix}-`)) {
    node.id = `${prefix}-${node.id}`
  }
  if ('children' in node && Array.isArray(node.children)) {
    for (const child of node.children) {
      ensureIdPrefix(child, prefix)
    }
  }
}

这样即使两个 Agent 都生成了 id: "title" 的节点,实际写入画布的是 hero-titlefeatures-title,不会冲突。

流式 JSONL 解析

Sub-Agent 输出的格式是 JSONL(每行一个 JSON 节点),通过 _parent 字段表达树形关系:

{"_parent":null,"id":"root","type":"frame","name":"Hero","width":"fill_container","height":"fit_content"}
{"_parent":"root","id":"title","type":"text","name":"Headline","content":"Learn Smarter","fontSize":48}
{"_parent":"root","id":"cta","type":"frame","name":"CTA Button","role":"button","width":180}
{"_parent":"cta","id":"cta-text","type":"text","content":"Get Started","fontSize":16}

流式解析器在每个 token 到达时就尝试提取完整的 JSON 对象:

// extractStreamingNodes — 在 rawResponse 上增量解析
const { results, newOffset } = extractStreamingNodes(rawResponse, streamOffset)

for (const { node, parentId } of results) {
  ensureIdPrefix(node, subtask.idPrefix)          // 加 ID 前缀
  addAgentIndicatorRecursive(node, agentColor, agentName)  // 加视觉标记
  markNodesForAnimation([node])                    // 标记动画
  insertStreamingNode(node, prefixedParent)        // 插入画布
}

每个节点在解析出来的瞬间就被插入画布,用户能看到 UI 元素像"打字机"一样逐个出现。

并发控制:信号量 + 屏幕分组

并发执行使用了一个手动实现的**信号量(Semaphore)**来限制同时进行的 API 调用数:

// 信号量实现
let activeSlots = 0
const waitQueue: (() => void)[] = []

async function acquireSlot() {
  if (activeSlots < concurrency) {
    activeSlots++
    return
  }
  // 如果槽位满了,等待释放
  await new Promise<void>((resolve) => waitQueue.push(resolve))
  activeSlots++
}

function releaseSlot() {
  activeSlots--
  if (waitQueue.length > 0) {
    waitQueue.shift()!()  // 唤醒等待中的 Agent
  }
}

同一屏幕内的子任务仍然串行执行(保证 Navigation → Hero → Features 的顺序),不同屏幕之间并行执行

// 每个 screen group 内部串行,不同 group 之间并行
const workers = screenGroups.map(async (indices) => {
  for (const idx of indices) {
    if (abortSignal?.aborted) return
    await acquireSlot()
    try {
      const result = await executeSubAgent(plan.subtasks[idx], ...)
      // 插入节点后扩展根 frame 高度
      if (result.nodes.length > 0) {
        expandRootFrameHeight(plan.subtasks[idx].parentFrameId)
      }
    } finally {
      releaseSlot()
    }
  }
})

await Promise.all(workers)

为什么不全部并行?因为垂直布局的页面,上下区域的视觉连贯性很重要。如果 Footer 比 Hero 先生成完毕,用户看到的就是颠倒的页面。

Phase 4:后处理

所有 Sub-Agent 完成后,执行后处理:

  • adjustRootFrameHeightToContent():根据实际内容调整根 Frame 高度
  • applyPostStreamingTreeHeuristics():应用基于树结构的启发式规则(按钮宽度、Frame 高度、clipContent 等)
  • zoomToFitContent():自动缩放画布以展示完整设计

Phase 5:视觉校验(可选)

利用 Vision API 对生成的设计截图进行自动校验,检测并修复视觉问题:

const validationResult = await runPostGenerationValidation({
  onStatusUpdate: (status, message) => {
    validationEntry.status = status
    validationEntry.thinking = message
    emitProgress(plan, progress, callbacks)
  },
  model: request.model,
  provider: request.provider,
})

三、Agent 身份系统

在并发模式下,多个 Agent 同时在画布上工作。为了让用户直观地看到"谁在画什么",我设计了一套 Agent Identity 系统:

// agent-identity.ts
const AGENT_COLORS = [
  '#FF6B6B', // coral red
  '#4ECDC4', // teal
  '#FFD93D', // golden yellow
  '#6C5CE7', // purple
  '#A8E6CF', // mint green
  '#FF8A5C', // warm orange
]

const AGENT_NAMES = [
  'Kiki', 'Mochi', 'Pixel', 'Nova', 'Zuri', 'Cleo',
  'Boba', 'Rune', 'Fern', 'Echo', 'Puck', 'Sage',
]

export function assignAgentIdentities(count: number): AgentIdentity[] {
  // Fisher-Yates 洗牌名字
  const shuffled = [...AGENT_NAMES]
  for (let i = shuffled.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1))
    ;[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]
  }
  return Array.from({ length: count }, (_, i) => ({
    color: AGENT_COLORS[i % AGENT_COLORS.length],
    name: shuffled[i % shuffled.length],
  }))
}

每个 Agent 在画布上的节点会显示带颜色的标记和名字(比如紫色的 "Pixel" 正在生成 Features 区域),Sub-Agent 完成后标记自动淡出:

// 完成后延迟移除标记,让用户能看到完成效果
setTimeout(() => removeAgentIndicatorsByPrefix(subtask.idPrefix), 1500)

标记通过 globalThis 上的共享 Map 管理,避免了 Vite 模块分割导致的状态隔离问题:

// agent-indicator.ts — 使用 globalThis 保证单例
const INDICATORS_KEY = '__openpencil_agent_indicators__'

function getIndicatorMap(): Map<string, AgentIndicatorEntry> {
  const g = globalThis as Record<string, unknown>
  if (!g[INDICATORS_KEY]) {
    g[INDICATORS_KEY] = new Map<string, AgentIndicatorEntry>()
  }
  return g[INDICATORS_KEY] as Map<string, AgentIndicatorEntry>
}

四、实时进度系统

用户在聊天面板中能看到每个 Agent 的实时状态,这通过 <step> 标签协议实现:

export function emitProgress(
  plan: OrchestratorPlan,
  progress: OrchestrationProgress,
  callbacks?: { onTextUpdate?: (text: string) => void },
): void {
  const planningStep = '<step title="Planning layout" status="done">...</step>'

  const subtaskSteps = plan.subtasks
    .map((st, i) => {
      const entry = progress.subtasks[i]
      const nodeInfo = entry.nodeCount > 0 ? ` (${entry.nodeCount} elements)` : ''
      return `<step title="${st.label}${nodeInfo}" status="${entry.status}">${entry.thinking ?? ''}</step>`
    })
    .join('\n')

  callbacks.onTextUpdate(`${planningStep}\n${subtaskSteps}`)
}

聊天面板解析这些 <step> 标签渲染为一个 Checklist UI:

✅ Planning layout
🔄 Navigation Bar (8 elements)       ← Agent "Kiki" 正在生成
⏳ Hero Section                       ← 等待中
⏳ Feature Cards                      ← 等待中
⏳ Footer                             ← 等待中

五、Sub-Agent 的 Prompt 工程

Sub-Agent 的 prompt 构建是整个系统中最需要精心打磨的部分。每个 Sub-Agent 收到的上下文包含:

1. 全局上下文(知道整体结构但不越界)

// 展示所有区域,标记当前 Agent 负责的区域
const sectionList = plan.subtasks
  .map((st) => {
    const marker = st.id === subtask.id ? ' ← YOU' : ''
    const elems = st.elements ? ` [${st.elements}]` : ''
    return `- ${st.label}${elems} (${st.region.width}x${st.region.height})${marker}`
  })
  .join('\n')

输出效果:

Page sections:
- Navigation Bar [logo, nav links, CTA button] (1200x72)
- Hero Section [headline, subtitle, illustration] (1200x560) ← YOU
- Feature Cards [3 cards with icon + title] (1200x480)
- Footer [links, copyright] (1200x200)

Generate ONLY "Hero Section" (~560px of content).
YOUR ELEMENTS: headline, subtitle, illustration
Do NOT generate elements listed in other sections.

2. 统一的 Style Guide 注入

if (plan.styleGuide) {
  prompt += `\nSTYLE GUIDE (use these consistently):
- Background: ${p.background}  Surface: ${p.surface}
- Text: ${p.text}  Secondary: ${p.secondary}
- Accent: ${p.accent}  Border: ${p.border}
- Heading font: ${sg.fonts.heading}  Body font: ${sg.fonts.body}
- Aesthetic: ${sg.aesthetic}`
}

3. 条件性指令注入

根据内容特征动态注入额外约束,而非一次性塞入所有规则:

// 检测到密集卡片场景 → 注入精简卡片指令
if (needsNativeDenseCardInstruction(subtask.label, compactPrompt, fullPrompt)) {
  prompt += `\nNATIVE DENSE-CARD MODE:
- Each card: max 2 text blocks only (title + one short metric)
- Rewrite long copy into concise keyword phrases
- Never use truncation marks ("..." or "…")`
}

// 检测到表格场景 → 注入表格结构指令
if (needsTableStructureInstruction(subtask.label, compactPrompt, fullPrompt)) {
  prompt += `\nTABLE MODE:
- Build table as explicit grid frames, NOT a single long text line
- Header must be its own horizontal row with separate cell frames`
}

这种按需注入策略避免了 prompt 膨胀——一个简单的导航栏不需要看到 20 条表格排版规则。

六、容错与降级策略

Agent Team 系统的健壮性来自多层容错:

1. Orchestrator 失败 → 启发式降级

如果编排 Agent 无法返回有效的 Plan,系统会根据用户 prompt 启发式地生成一个默认 Plan:

// orchestrator-prompt-optimizer.ts
export function buildFallbackPlanFromPrompt(prompt: string): OrchestratorPlan {
  // 根据关键词检测设计类型
  const isMobile = /mobile|移动|手机|login|登录|profile/i.test(prompt)
  const width = isMobile ? 375 : 1200
  const height = isMobile ? 812 : 0
  // 生成一个简单的单区域 Plan
  return {
    rootFrame: { id: 'page', width, height, layout: 'vertical' },
    subtasks: [{ id: 'main', label: 'Main Content', region: { width, height: 600 } }],
  }
}

2. 流式解析失败 → 批量回退

如果流式 JSONL 解析器没有提取到任何节点(可能是 LLM 输出格式偏差),在流结束后尝试批量解析:

// 流式解析失败时的后备方案
if (nodes.length === 0 && rawResponse.trim().length > 0) {
  const fallbackNodes = extractJsonFromResponse(rawResponse)
  if (fallbackNodes && fallbackNodes.length > 0) {
    for (const node of fallbackNodes) {
      ensureIdPrefix(node, subtask.idPrefix)
      insertStreamingNode(node, targetParent)
    }
  }
}

3. 部分失败不影响整体

某个 Sub-Agent 超时或报错时,只有该区域标记为 error,其他已完成的区域正常保留:

const collected = results.filter((r): r is SubAgentResult => r !== null)
const totalNodes = collected.reduce((sum, r) => sum + r.nodes.length, 0)

// 只有当所有 Agent 都返回 0 个节点时才抛错
if (totalNodes === 0 && collected.length > 0) {
  throw new Error('All sub-agents failed')
}

4. 用户随时中断

通过 AbortSignal 实现优雅的中断——已生成的节点保留在画布上,只是停止后续生成:

for (let i = 0; i < plan.subtasks.length; i++) {
  if (abortSignal?.aborted) break  // 用户点了停止按钮
  const result = await executeSubAgent(...)
}

七、流式超时与心跳

与 LLM 的长连接需要精心的超时管理:

export interface StreamTimeoutConfig {
  hardTimeoutMs: number       // 总超时(墙钟时间)
  noTextTimeoutMs: number     // 无活动超时(收到任何内容就重置)
  firstTextTimeoutMs?: number // 等待第一个 token 的超时
  thinkingResetsTimeout: boolean  // thinking token 是否重置超时
  pingResetsTimeout?: boolean     // 心跳是否重置超时
}

服务端每 15 秒发送一次 keep-alive ping,防止 SSE 连接被中间代理切断:

// server/api/ai/chat.ts
const keepAlive = setInterval(() => {
  writer.write(`data: ${JSON.stringify({ type: 'ping' })}\n\n`)
}, 15000)

超时时长根据 prompt 长度动态调整——简短的 "login page" 不需要和复杂的 "电商平台首页" 用同样的超时:

export function getSubAgentTimeouts(promptLength: number): StreamTimeoutConfig {
  if (promptLength < 200) return { hardTimeoutMs: 60_000, noTextTimeoutMs: 25_000, ... }
  if (promptLength < 500) return { hardTimeoutMs: 90_000, noTextTimeoutMs: 30_000, ... }
  return { hardTimeoutMs: 120_000, noTextTimeoutMs: 40_000, ... }
}

八、设计原则与经验总结

1. Orchestrator 要"轻"

编排 Agent 只做分解,不做任何设计决策。它的输出是结构化的 JSON Plan,而非自然语言。这使得解析稳定、执行确定。

2. 隔离胜过协调

相比让多个 Agent "互相通信",完全隔离 + ID 命名空间是更可靠的并行策略。每个 Agent 看到全局结构(知道自己是哪个区域),但无法影响其他 Agent 的输出。

3. 流式优先,批量兜底

用户体验的关键在于感知速度。即使总时间没变,逐个节点出现比等 30 秒后"砰"一下全出要好得多。但流式解析必须有批量回退兜底,因为 LLM 输出格式不总是完美的。

4. 渐进式高度扩展

根 Frame 的高度在生成过程中只会增长,不会缩短。这避免了"画布抖动"——内容一多一少地跳来跳去。只在所有 Agent 完成后才做最终的高度调整。

5. 给 Agent 一个身份

这看起来是个小功能,但 Agent Identity(颜色 + 名字)极大提升了用户对并行过程的理解和信任感。用户能看到 "Kiki 正在画导航栏"、"Mochi 在画 Hero",而不是一堆节点不知道从哪冒出来。

九、架构全景

flowchart TD
    Input["用户输入<br/>&quot;设计一个 SaaS 产品的 Landing Page&quot;"]
    Classify["意图分类 classifyIntent<br/>DESIGN / CHAT / MODIFY"]
    Entry["executeOrchestration()"]

    Input --> Classify
    Classify -->|DESIGN| Entry

    subgraph Phase1["Phase 1: Planning"]
        Orch["Orchestrator Agent<br/>callOrchestrator()<br/>输出: OrchestratorPlan<br/>(rootFrame + styleGuide + subtasks)"]
    end

    subgraph Phase2["Phase 2: Canvas Setup"]
        Setup["创建根 Frame + Agent 身份"]
    end

    subgraph Phase3["Phase 3: Sub-Agent 执行"]
        Agents["executeSubAgents()"]
        NavAgent["Nav Agent"]
        HeroAgent["Hero Agent"]
        FeatAgent["Feat Agent"]
        Insert["insertStreamingNode()<br/>markNodesForAnimation()"]

        Agents --> NavAgent & HeroAgent & FeatAgent
        NavAgent -->|JSONL| Insert
        HeroAgent -->|JSONL| Insert
        FeatAgent -->|JSONL| Insert
    end

    subgraph Phase4["Phase 4: 后处理"]
        Post["adjustRootFrameHeight()<br/>applyTreeHeuristics()"]
    end

    subgraph Phase5["Phase 5: 视觉校验"]
        Valid["runPostGenerationValidation()<br/>Vision API 校验"]
    end

    Entry --> Phase1
    Phase1 --> Phase2
    Phase2 --> Phase3
    Phase3 --> Phase4
    Phase4 --> Phase5

十、总结

实现一个 Agent Team 系统,核心挑战不在于"调 API",而在于:

  1. 任务分解的质量 — Orchestrator 的 prompt 决定了整个管线的上限
  2. 并发安全 — ID 隔离 + 信号量,比锁更适合前端场景
  3. 流式体验 — JSONL 增量解析 + 实时画布渲染 + 动画
  4. 优雅降级 — 每一层都有 fallback,部分失败不影响整体
  5. 可观测性 — Agent Identity + 进度 Checklist,让黑盒变透明

这套架构已经在 OpenPencil 中稳定运行,支持 1-6x 并发度,能在几秒内生成包含 100+ 节点的复杂 UI 设计。

如果你对设计工具、AI 生成或多 Agent 系统感兴趣,欢迎查看 OpenPencil 的源码,所有代码都是开源的。


关于 OpenPencil

OpenPencil 是全球首个开源 AI 原生矢量设计工具,也是业界首个实现并发 Agent Team 协作生成的设计工具。

核心特性:

  • AI 原生设计 — 输入一句话,多个 AI Agent 协作在画布上实时生成完整 UI,支持 1-6x 并发
  • Design-as-Code — 设计即代码,一键导出 React + Tailwind / HTML + CSS,设计变量自动生成 CSS Variables
  • 专业矢量编辑 — 基于 Fabric.js v7,支持 Frame、Auto Layout、布尔运算、钢笔工具、智能参考线等专业功能
  • 设计变量系统 — 完整的 Design Token 管理,支持多主题轴(Light/Dark、Compact/Comfortable)
  • Figma 导入 — 直接解析 .fig 文件,无需通过 API
  • MCP Server — 内置 MCP 服务器,可被 Claude Code、Cursor 等 AI 工具直接调用
  • 全平台桌面端 — 基于 Electron,支持 macOS、Windows、Linux,含自动更新

如果觉得有用,欢迎去 GitHub 点个 Star 支持一下!