AgentLab:可视化 Agent 编排实验台

在画布上拖拽编排 agent 工作流,并实时看着它执行。

后端 FastAPI + LangGraph:把画布 JSON 编译成 StateGraph,15 种节点
(模型/Agent/多Agent、工具/沙箱、分支/循环/子图、记忆/检索/整形、
人工介入/结构校验)。选 LangGraph 是因为可视化编排真正难的是暂停、
恢复和回放,而 checkpointer + interrupt() 正好把这几件事做在了底层。

前端 React + React Flow:节点元数据驱动属性面板与连接桩,运行时
节点高亮、边流动画、卡片内流式 token 全部由同一份事件流驱动。

关键实现:
- Agent 内工具审批用 @task 缓存模型调用,interrupt 重放时不重复计费
- 沙箱保持只读根文件系统,代码经 exec+base64 写入(put_archive 会被
  只读容器拒绝),断网、非 root、内存/CPU/超时限额
- 检索为向量与 BM25 混合,本地哈希 embedding 零配置可用
- 分支条件走 AST 白名单求值器;HTTP 工具逐 IP 拦截内网与元数据地址
- Mock provider 让无 API Key 也能跑通完整链路

7 个内置模板全部冒烟通过,含真实模型端到端验证。
This commit is contained in:
ThearchyHelios
2026-09-13 17:27:00 +08:00
commit 964eeaa829
89 changed files with 17280 additions and 0 deletions
Executable
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# 一条命令把前后端都拉起来。Ctrl-C 一起退出。
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
API_PORT="${AGENTLAB_PORT:-8000}"
WEB_PORT="${AGENTLAB_WEB_PORT:-5273}"
command -v uv >/dev/null || { echo "缺少 uv:brew install uv"; exit 1; }
command -v pnpm >/dev/null || { echo "缺少 pnpm:npm i -g pnpm"; exit 1; }
echo "==> 同步依赖"
(cd "$ROOT/backend" && uv sync --quiet)
[ -d "$ROOT/frontend/node_modules" ] || (cd "$ROOT/frontend" && pnpm install)
cleanup() {
echo
echo "==> 停止"
# 杀掉整个进程组,避免 uvicorn / vite 变成孤儿进程
[ -n "${API_PID:-}" ] && kill "$API_PID" 2>/dev/null || true
[ -n "${WEB_PID:-}" ] && kill "$WEB_PID" 2>/dev/null || true
wait 2>/dev/null || true
}
trap cleanup EXIT INT TERM
echo "==> 后端 http://127.0.0.1:$API_PORT"
(cd "$ROOT/backend" && uv run uvicorn app.main:app --host 127.0.0.1 --port "$API_PORT" --reload) &
API_PID=$!
# 等后端起来再起前端,省得前端第一次请求就打空
for _ in $(seq 1 60); do
curl -sf "http://127.0.0.1:$API_PORT/api/health" >/dev/null 2>&1 && break
sleep 0.5
done
echo "==> 前端 http://localhost:$WEB_PORT"
(cd "$ROOT/frontend" && pnpm dev --port "$WEB_PORT") &
WEB_PID=$!
echo
echo " 界面 http://localhost:$WEB_PORT"
echo " 接口 http://127.0.0.1:$API_PORT/docs"
echo
wait
+48
View File
@@ -0,0 +1,48 @@
// 冒烟测试:真开一个浏览器,跑一遍「打开画布 → 选模板 → 运行 → 看高亮」。
// 依赖 frontend 里的 playwright-core,并复用系统已装的 Chrome,不额外下载浏览器。
import { chromium } from '../frontend/node_modules/playwright-core/index.mjs'
const browser = await chromium.launch({
executablePath: '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', args: ['--no-sandbox'] })
const page = await browser.newPage({ viewport: { width: 1680, height: 1000 } })
const errs = []
page.on('pageerror', e => errs.push(e.message))
page.on('console', m => m.type() === 'error' && errs.push(m.text()))
await page.goto('http://localhost:5273/studio', { waitUntil: 'networkidle' })
await page.waitForSelector('.react-flow__node', { timeout: 15000 })
// 切到「最小问答」模板,用 mock 模型跑,验证画布实时高亮
await page.getByRole('button', { name: /批量处理|最小问答|工作流/ }).first().click()
await page.waitForTimeout(600)
const row = page.locator('text=① 最小问答').first()
if (await row.count()) { await row.click(); await page.waitForTimeout(900) }
console.log('当前图节点数:', await page.locator('.react-flow__node').count())
// 选中 llm 节点,把模型改成 mock,避免这次验证花真钱
await page.locator('.react-flow__node').nth(1).click()
await page.waitForTimeout(500)
const modelSel = page.locator('select').filter({ hasText: /默认|Mock|Claude/ }).first()
if (await modelSel.count()) { await modelSel.selectOption({ label: 'Mock(不花钱,用来试编排)' }).catch(()=>{}) }
await page.screenshot({ path: '/tmp/shot-inspector.png' })
// 回到运行面板,填输入并运行
await page.getByRole('button', { name: '运行', exact: true }).first().click()
await page.waitForTimeout(300)
const ta = page.locator('aside textarea').first()
await ta.fill('可视化 agent 编排解决了什么问题?')
await page.getByRole('button', { name: /^运行$/ }).last().click()
// 等到有节点进入运行态,抓一张"正在跑"的画面
await page.waitForSelector('.node-running, .node-done', { timeout: 20000 }).catch(()=>{})
await page.waitForTimeout(1200)
await page.screenshot({ path: '/tmp/shot-running.png' })
console.log('运行中高亮节点:', await page.locator('.node-running').count(), '已完成:', await page.locator('.node-done').count())
await page.waitForTimeout(6000)
await page.screenshot({ path: '/tmp/shot-done.png' })
console.log('完成后 done 节点:', await page.locator('.node-done').count())
const timeline = await page.locator('aside').last().innerText()
console.log('--- 右侧面板文本 ---'); console.log(timeline.slice(0, 700))
console.log('--- 错误 ---'); console.log(errs.slice(0,6).join('\n') || '(无)')
await browser.close()