前言
在 OpenPencil(一个开源矢量设计工具)的 AI 设计生成功能中,我实现了一套多智能体编排系统(Agent Team)。用户输入一句话描述,系统会自动将其拆解为多个子任务,分配给不同的 Sub-Agent 并行生成,最终在画布上实时呈现完整的 UI 设计。
本文将从架构设计、核心实现、并发控制、流式渲染等角度,详细拆解这套系统的实现过程。
一、为什么需要 Agent Team?
单 Agent 的瓶颈
最初的设计生成是"一锅端":把整个 UI 描述扔给一个 LLM,让它一次性输出所有节点。问题很快暴露:
- 上下文爆炸:一个完整的 Landing Page 可能包含 100+ 节点,LLM 在后半段容易"遗忘"前面的设计约束
- 速度太慢:复杂页面需要 30-60 秒才能完成,用户盯着空白画布等待
- 错误传播:一个区域出错(比如 Hero 排版溢出),后续所有区域都会受到影响
- 无法并行:单次 API 调用是串行的,无法利用多路并发加速
Agent Team 的思路
借鉴软件工程中的分治策略:
graph TD
O["Orchestrator<br/>(快速规划 Agent)"]
O -->|OrchestratorPlan| A1["Agent #1 "Kiki"<br/>导航栏"]
O -->|OrchestratorPlan| A2["Agent #2 "Mochi"<br/>Hero 区"]
O -->|OrchestratorPlan| A3["Agent #3 "Pixel"<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-title 和 features-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/>"设计一个 SaaS 产品的 Landing Page""]
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",而在于:
- 任务分解的质量 — Orchestrator 的 prompt 决定了整个管线的上限
- 并发安全 — ID 隔离 + 信号量,比锁更适合前端场景
- 流式体验 — JSONL 增量解析 + 实时画布渲染 + 动画
- 优雅降级 — 每一层都有 fallback,部分失败不影响整体
- 可观测性 — 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 支持一下!