
Electric Agents Walkthrough 实战指南用 Hono 从零搭建可扩展的多 Agent 系统【免费下载链接】electricThe agent platform built on sync.项目地址: https://gitcode.com/GitHub_Trending/el/electric本指南以仓库内examples/agents-walkthrough示例为核心逐步讲解如何把一个仅有Hello Hono!的裸 Web 应用演进成一个具备LLM 决策 持久化状态驱动确定性流程能力的多 Agent 系统。读完本文你将掌握 Electric Agents 的实体注册、运行时 Webhook 接线、子 Agent 派生ctx.spawn、消息路由ctx.send/ctx.sleep、持久化状态集合ctx.state以及跨实体观察ctx.observe等核心能力并理解混合控制流这一让 Agent 系统既灵活又可靠的关键设计。示例是什么一份可对照官方向导逐行演进的参考实现examples/agents-walkthrough是官方 Walkthrough 向导 的配套参考代码。该向导的目标是从新建或已有的 Web/移动应用出发一步步把它变成动态的多 Agent 系统。示例仓库通过 6 份源码文件记录了完整的演进过程文件对应向导阶段核心内容src/index0.ts起点裸 Hono 应用只返回Hello Hono!src/index1.tsStep 1定义assistant实体并接入运行时src/index2.tsStep 2manager在 handler 里命令式ctx.spawn子 Agentsrc/index3.tsStep 3用工具调用tool call派生子 Agentsrc/index4.tsStep 4judge实体编排双方辩论纯提示词驱动src/index5.tsStep 5混合控制流持久化状态 命令式流程其中默认的 src/index.ts 是index5.ts的拷贝即向导最后一节混合控制流的成品形态——你可以直接以它为起点运行、研究。运行示例三步起一个本地多 Agent 环境前置依赖与 Quickstart 一致需要Node.js 18含 pnpmDocker本地开发服务器跑在容器里Anthropic API Key安装与启动在examples/agents-walkthrough目录下执行pnpm install pnpm devpackage.json中的dev脚本为dev: tsx watch --env-file.env src/index.ts也就是说示例通过tsx watch以 Node 直接运行 TypeScript 源码并通过--env-file.env加载环境变量——你需要先在该目录创建.env文件并写入ANTHROPIC_API_KEYsk-ant-...先启动 Agents 运行时服务器示例本身只是一个 Web 应用真正的 Agent 运行环境Postgres、Electric 与 Electric Agents 服务器内含 Durable Streams 服务器与 Agents UI由独立的开发服务器提供通过以下命令启动pnpx electric-axlatest agents start启动成功后输出类似Electric Agents dev environment is up. Server UI: http://localhost:4437 Docker project: electric-agents注意这里用的是agents start而非agents quickstart因此不会预装horton、worker等默认实体而是得到一个干净运行时正好用于从零定义自己的 Agent 实体。可以用 CLI 验证运行时是否就绪pnpx electric-axlatest agents types此时应显示一个空的Built-in agents列表。用 Caddy 代理到 HTTP/2本地开发建议向导建议在宿主机安装 Caddy 并执行caddy trust然后在示例目录放置 Caddyfile{ log default { level ERROR } } localhost:4438 { reverse_proxy localhost:4437 { flush_interval -1 } encode gzip header { Cache-Control no-cache, no-transform X-Accel-Buffering no } }随后执行caddy start将https://localhost:4438代理到http://localhost:4437让浏览器以 HTTP/2 连接 Electric避免本地开发时的 shape/SSE 连接性能问题。关键常量与依赖三份演进文件中都硬编码了以下常量生产环境应改为环境变量注入const PORT 3000 const SERVE_URL http://localhost:${PORT} const ELECTRIC_AGENTS_URL http://localhost:4437 const MODEL claude-sonnet-4-6依赖方面示例仅使用四个包见 package.jsonelectric-ax/agents-runtime运行时核心EntityRegistry、RuntimeHandler、ctx上下文honohono/node-serverWeb 框架与 Node 服务器sinclair/typebox为工具参数提供运行时 schema 与类型推导Step 1从裸 Hono 到第一个 Agent 实体注册实体createEntityRegistry与registry.define向导从src/index.ts中的最小 Hono 应用起步。要接入 Electric Agents先导入运行时 shimimport { createEntityRegistry, createRuntimeHandler, } from electric-ax/agents-runtimecreateEntityRegistry()实现在 packages/agents-runtime/src/define-entity.ts创建一个实体注册表所有 Agent 类型都通过它定义。最简单的实体——一个通用助手——只需三块内容registry.define(assistant, { description: A general-purpose AI assistant, async handler(ctx) { ctx.useAgent({ systemPrompt: You are a helpful assistant., model: MODEL, tools: [], }) await ctx.agent.run() }, })handler(ctx)是实体的入口ctx.useAgent({...})配置本次运行使用的模型、系统提示词与工具await ctx.agent.run()把控制权交给 LLM 完成一轮对话。这个助手没有任何工具只能聊天回复。运行时接线createRuntimeHandler与 Webhook接下来创建运行时处理器并把它挂到 Hono 路由上const runtime createRuntimeHandler({ baseUrl: ELECTRIC_AGENTS_URL, serveEndpoint: ${SERVE_URL}/electric-agents, registry, }) app.post(/electric-agents, (c) { return runtime.handleWebhookRequest(c.req.raw) })createRuntimeHandler实现在 packages/agents-runtime/src/create-handler.ts接收三个配置baseUrl指向 Electric Agents 服务器、serveEndpoint是当前应用暴露给运行时的回调地址、registry是上面定义的实体注册表。runtime.handleWebhookRequest把运行时的 Webhook 通知转交给对应实体处理。这里有个值得理解的通信机制Agent 之间、Agent 与运行时之间的实际消息都通过 Durable Streams 传输使用内置的 StreamDB 集合通知系统负责唤醒wakeAgent 告诉它有新数据可消费。这样 Agent 在无事件时可以休眠、缩容到零。注册实体类型registerTypes最后在服务器启动回调中注册实体类型让运行时服务器知道本应用提供哪些 Agentserve( { fetch: app.fetch, port: PORT, }, (info) { console.log(Server is running on http://localhost:${info.port}) runtime.registerTypes().catch(console.error) } )启动后日志会出现INFO: [agent-runtime] Registered entity type: assistant再执行pnpx electric-axlatest agents types就能看到http://host.docker.internal:3000/electric-agents NAME DESCRIPTION ─────────────────────── ──────────────────────────────────────── assistant A general-purpose AI assistant Built-in agents NAME DESCRIPTION ─────────────────────── ────────────────────────────────────────打开https://localhost:4438注意走 Caddy 代理的 HTTPS 端口点击 New session 即可在实体列表里看到assistant直接新建会话与它聊天Step 2命令式派生——第一个多 Agent 系统这一阶段故意使用朴素方式定义一个manager实体每收到一条用户消息就在 handler 里命令式地派生一个子 Agent。先扩展assistant让它支持通过ctx.args传入自定义systemPrompt即派生时动态指定行为并增加一个生成实体 ID 的辅助函数registry.define(assistant, { description: A general-purpose AI assistant, async handler(ctx) { ctx.useAgent({ systemPrompt: (ctx.args.systemPrompt as string) || You are a helpful assistant., model: MODEL, tools: [], }) await ctx.agent.run() }, }) const genId () Math.random().toString()再定义manager实体registry.define(manager, { description: A manager agent that delegates work to assistants, async handler(ctx, wake) { if (wake.type inbox) { await ctx.spawn( assistant, genId(), { systemPrompt: Reverse the user message., }, { initialMessage: (wake.payload as { text: string }).text, wake: { on: runFinished, includeResponse: true }, } ) } ctx.useAgent({ systemPrompt: (ctx.args.systemPrompt as string) || You are a manager agent., model: MODEL, tools: [], }) await ctx.agent.run() }, })这段代码展示了两个关键点wake.type inboxhandler的第二个参数wake是唤醒事件。用户消息来自 inbox 流其wake.type为inbox而子 Agent 完成一轮运行触发的通知wake.type为wake。这里只对用户消息做处理。ctx.spawn(type, id, args, opts)派生一个指定类型的子实体。args会被透传给子实体的ctx.args此处用于注入systemPromptopts.initialMessage是发给子实体的首条消息opts.wake指定子实体运行完成runFinished时唤醒父实体并在唤醒载荷里带上其回复includeResponse: true。在实际运行中你会在 UI 左侧菜单看到 manager 1展开能看到派生的子 Agent。但向导刻意指出一个问题manager 收到了子 Agent 的响应通知却不理解它——它不知道是自己派生了这个子 Agent、也不知道回复对应自己的指令。因为子 Agent 是在命令式代码里派生的没有以工具调用的形式记录在会话上下文中LLM 看不到这层因果Step 3工具调用派生——让 LLM 看到并理解子 Agent问题根源在于派生动作发生在会话上下文之外的代码里。解法是把派生封装成工具调用让 manager 的会话日志完整记录spawn 了一个 assistant 去反转消息这件事。先安装类型辅助库并导入pnpm add sinclair/typeboximport { Type, type Static } from sinclair/typebox定义spawn_assistant工具。工具的parameters用 TypeBox 声明 LLM 需要生成的入参ctx.spawn移入execute工具返回值包含文本响应、结构化details携带entityUrl以及terminate: true结束本轮const taskParameters Type.Object({ task: Type.String({ description: The task for the assistant. }), }) type TaskParams Statictypeof taskParameters function createSpawnAssistantTool(ctx: HandlerContext) { return { name: spawn_assistant, label: Spawn Assistant, description: Spawn an assistant sub-agent to perform a task., parameters: taskParameters, execute: async (_toolCallId: string, params: unknown) { const { task } params as TaskParams const { entityUrl } await ctx.spawn( assistant, genId(), {}, { initialMessage: task, wake: { on: runFinished, includeResponse: true }, } ) return { content: [ { type: text as const, text: Assistant dispatched at ${entityUrl}., }, ], details: { entityUrl }, terminate: true, } }, } }随后把 manager 简化删除命令式的ctx.spawn改为在系统提示词中描述决策规则并把工具放进tools数组见 src/index3.tsregistry.define(manager, { description: A manager agent that delegates work to an assistant, async handler(ctx) { ctx.useAgent({ systemPrompt: When given a user message that is a single word, spawn an assistant to reverse the user message. When asked direct questions, answer them yourself. , model: MODEL, tools: [createSpawnAssistantTool(ctx)], }) await ctx.agent.run() }, })现在向 manager 发消息它会自主决定何时调用spawn_assistant由于工具调用记录在会话日志里manager 能理解子 Agent 的存在与来源。向导建议的验证方式是直接问它who reversed this message? how did that happen/work?它能够解释刚才发生了什么——这正是工具调用上下文可见性带来的能力提升。Step 4多 Agent 辩论——纯提示词编排的局限Judge 实体与三层派生下一步定义一个更复杂的实体judge它要派生两个子 Agent 分别为正反方辩护最后给出裁决。除 manager 外manager 又通过spawn_judge工具派生 judgesrc/index4.ts形成 manager → judge → assistant×2 的三层派生链。judge的核心工作几乎全部写在系统提示词里同时获得createSpawnAssistantTool(ctx)用于派生两名辩手。manager 的系统提示词也相应扩展registry.define(manager, { description: A manager agent that delegates work to an assistant, async handler(ctx) { ctx.useAgent({ systemPrompt: When asked to debate a topic, spawn a Judge with the debate topic. When given a user message that is a single word, spawn an assistant to reverse the user message. When asked direct questions, answer them yourself. , model: MODEL, tools: [createSpawnAssistantTool(ctx), createSpawnJudgeTool(ctx)], }) await ctx.agent.run() }, })在 UI 中新建 manager 会话并下达例如 Debate 996 vs 4-day-week 的指令可以看到它派生 judgejudge 再派生两名 assistant。Make no mistakes提示词兜底的脆弱性纯提示词编排的问题很快暴露LLM 的行为是非确定性的。向导指出运行结果会逐次变化——judge 和 manager 常常在参数还没真正返回时就开始脑补辩论结果。为此向导在 judge 提示词中加入了一组防幻觉注意事项Notes: - You are an impartial judge. - Use the assistants to gather the two sides. - Wait for **all** of the assistants to return **full** responses. Dont respond to partial / in-progress responses. - Do not generate/hallucinate the argument yourself. You must wait for the assistants to fully respond and then synthesize their responses. Dont anticipate or make them up. - Wait until the debate is fully finished before reporting back to the parent agent.这些约束对较强模型往往有效但 LLM 永远存在小概率不遵循指令。当流程变得更复杂例如向导设想的双方先陈述论点、再把对方论点交给对方反驳、最后裁决的三阶段辩论仅靠更长、更细的提示词极易脱轨go off-piste。向导随后画出了目标辩论时序图manager 下发 topic 后judge 进入 phase 1arguing双方陈述论点phase 2critiquing双方互相反驳phase 3verdictjudge 总结裁决并回传给 manager。这是引入Step 5 混合控制流的动机——与其把每一步都押在 LLM 的提示词理解上不如把何时该做什么变成由持久化状态驱动的确定性代码。Step 5混合控制流——状态机化的可靠多 Agent 编排这是示例的成品形态即默认 src/index.ts也是向导的核心章节。思路是让 LLM 做它擅长的事理解语义、撰写辩题摘要、写出裁决让代码 持久化状态做它擅长的事控制流程何时推进、等待谁、向谁转发。具体分四步改造1. 定义start_debate工具把派生两名辩手变成确定性的工具调用const startDebateParameters Type.Object({ topic: Type.String({ description: Short topic line, e.g. 996 vs 4-day work week., }), aBrief: Type.String({ description: Brief for the A debater: topic, side, ask for their concise argument and points., }), bBrief: Type.String({ description: Brief for the B debater: same shape. }), }) type StartDebateParams Statictypeof startDebateParameters function createStartDebateTool(ctx: HandlerContextany, any, any, any) { return { name: start_debate, label: Start Debate, description: Spawn the two debaters with their opening briefs. Call exactly once., parameters: startDebateParameters, execute: async (_id: string, params: unknown) { const { topic, aBrief, bBrief } params as StartDebateParams const [a, b] await Promise.all([ ctx.spawn( assistant, genId(), {}, { initialMessage: aBrief, wake: { on: runFinished, includeResponse: true }, } ), ctx.spawn( assistant, genId(), {}, { initialMessage: bBrief, wake: { on: runFinished, includeResponse: true }, } ), ]) ctx.state.debate.insert({ key: current, topic, aUrl: a.entityUrl, bUrl: b.entityUrl, phase: arguing, arguments: {}, rebuttals: {}, }) return { content: [{ type: text as const, text: Debate started. }], details: {}, terminate: true, } }, } } const rebut (arg: string) Your opponent argued:\n\n${arg}\n\nRebut their argument(s).注意LLM 仍负责撰写双方的 briefaBrief/bBrief这是语义判断但派生两个子 Agent 并记录初始状态变成了确定性代码。ctx.state.debate.insert({...})把辩论状态双方entityUrl、当前phase、论点与反驳记录写入实体的持久化状态。2. 声明debate持久化集合在judge实体的定义上增加state配置。Electric Agents 底层使用 TanStack DB集合collection是核心响应式数据抽象这里用passthrough直接透传自定义的Debate类型 schema并以key为主键type Debate { key: current topic: string aUrl: string bUrl: string phase: arguing | critiquing | done arguments: { a?: string; b?: string } rebuttals: { a?: boolean; b?: boolean } }registry.define(judge, { description: Coordinates a three-phase debate: arguments, mutual rebuttals, verdict., state: { debate: { schema: passthroughDebate(), primaryKey: key }, }, async handler(ctx, wake) { // ... }, })passthrough与entity的实现在 packages/agents-runtime/src/entity-schema.ts 与 packages/agents-runtime/src/observation-sources.ts。3. 重写 judge handler状态驱动的确定性流程完整的 judge handler 逻辑src/index.ts 中registry.define(judge, ...)如下const SETUP_PROMPT You are a fair, concise debate judge opening a debate. Call start_debate exactly once: pick the topic line, and write a clear brief for each side: - A argues one case (e.g.: beneficial / pro / one side of the argument) - B argues the other case (e.g.: harmful / against / the other side) Each brief assigns only the topic and that sides position, then asks the debater to make a concise argument with their own three strongest points. Do NOT supply, list, or hint at any arguments yourself — the debater must devise their own. Then end your turn. Do not narrate. const VERDICT_PROMPT You are a fair, concise debate judge closing a debate. Both sides have argued and critiqued each other. The full exchange is in your context. Weigh it and write your final verdict as your reply: summarise each sides strongest points, note how each critique landed, and give your impartial decision. Never argue a side. Do not narrate or preface — your reply IS the verdict, and it gets relayed to the user. registry.define(judge, { description: Coordinates a three-phase debate: arguments, mutual rebuttals, verdict., state: { debate: { schema: passthroughDebate(), primaryKey: key }, }, async handler(ctx, wake) { // Handle inbox messages by spawning one debate at a time. // Using the LLM to formulate the briefs for each side. if (wake.type inbox) { if (ctx.state.debate.get(current)) { return ctx.sleep() } ctx.useAgent({ systemPrompt: SETUP_PROMPT, model: MODEL, tools: [createStartDebateTool(ctx)], }) await ctx.agent.run() return } // Ignore wake notifications unless theyre from finished children // participating in the current debate. let debate ctx.state.debate.get(current) if (!debate || debate.phase done) { return ctx.sleep() } const finished_child ( wake.payload as { finished_child?: FinishedChild } | undefined )?.finished_child if (!finished_child) { return ctx.sleep() } const side finished_child.url debate.aUrl ? a : finished_child.url debate.bUrl ? b : null if (!side) { return ctx.sleep() } // Record this debaters contribution for the current round. if (debate.phase arguing) { ctx.state.debate.update(current, (d) { d.arguments[side] finished_child.response ?? }) debate ctx.state.debate.get(current)! // Proceed once both debaters have reported for this round. if ( debate.arguments.a ! undefined debate.arguments.b ! undefined ) { ctx.send(debate.aUrl, rebut(debate.arguments.b)) ctx.send(debate.bUrl, rebut(debate.arguments.a)) ctx.state.debate.update(current, (d) { d.phase critiquing }) } return ctx.sleep() } // Were in phase critiquing, wait until both are in. ctx.state.debate.update(current, (d) { d.rebuttals[side] true }) debate ctx.state.debate.get(current)! if (!debate.rebuttals.a || !debate.rebuttals.b) { return ctx.sleep() } // Flip the phase to done and have the LLM write the verdict as its reply. ctx.state.debate.update(current, (d) { d.phase done }) ctx.useAgent({ systemPrompt: VERDICT_PROMPT, model: MODEL, tools: [], }) await ctx.agent.run() }, })这段 handler 展示了混合控制流的完整形态inbox 分支收到用户消息时若已有进行中的辩论ctx.state.debate.get(current)存在则ctx.sleep()忽略否则用SETUP_PROMPT运行 LLM让它撰写双方 brief 并调用start_debate工具。wake 分支arguing 阶段根据wake.payload.finished_child的url判断是哪位辩手完成把其回复写入arguments[side]。只有当双方论点都到齐才用ctx.send把 A 的论点发给 B 反驳、B 的论点发给 A 反驳并把phase置为critiquing。任何单方到达都直接ctx.sleep()。wake 分支critiquing 阶段记录rebuttals[side] true双方反驳到齐后把phase置为done最后用VERDICT_PROMPT运行 LLM把裁决作为回复输出会被回传给 manager。ctx.sleep()在此处的语义是本轮无事可做挂起等待下一个唤醒事件——这正是 Agent 能够休眠、按需唤醒、缩容到零的机制。4. 更新 manager用ctx.observe观察子实体状态最后manager 在收到 judge 的runFinished唤醒时不再直接采信其回复而是用ctx.observe建立对子实体的观察读取其持久化状态确认辩论确实结束registry.define(manager, { description: Delegates to assistants and judges and relays their results to the user., async handler(ctx, wake) { if (wake.type wake) { const finishedChild ( wake.payload as { finished_child?: FinishedChild } | undefined )?.finished_child if ( finishedChild?.type judge finishedChild.run_status completed ) { const judge await ctx.observe(entity(finishedChild.url)) const debate judge.db.collections.debate.get(current) as | Debate | undefined if (debate?.phase ! done) { return ctx.sleep() } } } ctx.useAgent({ systemPrompt: When asked to debate a topic, spawn a Judge with the debate topic. When given a user message that is a single word, spawn an assistant to reverse the user message. When asked direct questions, answer them yourself. , model: MODEL, tools: [createSpawnAssistantTool(ctx), createSpawnJudgeTool(ctx)], }) await ctx.agent.run() }, })ctx.observe(entity(url))返回对子实体的实时观察句柄可以像访问本地数据库一样读取其集合judge.db.collections.debate.get(current)。这是一套非常强大的机制Agent 之间无需预先定义 API 或通信接口直接以内置的流和持久化状态进行订阅式协作。5. 整场辩论的唤醒-决策矩阵向导给出了 judge 在整场辩论中收到的全部唤醒事件以及每个事件上的闸门决策直观展示为什么混合控制流下辩论不可能提前结束#Judge 唤醒闸门决策Judge 运行 LLM?Manager 被唤醒?Manager 运行 LLM?1inbox尚无辩论 → 设置是start_debate是否辩论未done2A 论点arguments.b缺失 → sleep否否否3B 论点双方论点齐 → 转发反驳否命令式转发否否4A 反驳rebuttals.b缺失 → sleep否否否5B 反驳双方反驳齐 → 裁决是写裁决是是辩论已done转发裁决关键结论整个流程不存在让模型提前交卷或脑补结果的路径。judge 只会在论点和反驳都到齐后总结manager 只会在辩论状态为done后转发裁决。LLM 的自由度被限定在撰写 brief、写裁决这类语义任务上而流程推进完全由持久化状态决定。这正是混合控制流的全部要义让 LLM 在需要解释力、创造力的地方保持表达自由同时用确定性状态把不可靠的部分锁死。系统的表达能力与可靠性因此可以兼得。源码与测试如何验证示例确实可运行仓库为 walkthrough 示例提供了冒烟测试 test/smoke.test.ts它用 vitest 以与pnpm dev相同的方式tsx src/index.ts启动默认的混合控制流成品然后断言服务器在 45 秒内完成启动并监听http://localhost:3000根路由返回 200 与Hello Hono!该测试刻意不连接 Electric Agents 运行时与 Anthropic API因此不需要 Docker 或 API Key服务器启动时registerTypes()无法触达 agents 服务器会报错被catch并记录但 HTTP 服务器本身仍然监听——测试只验证能启动、能服务。运行方式pnpm test此外CHANGELOG.md 记录了该示例与electric-ax/agents-runtime的版本跟随关系当前示例版本 0.1.11对应 runtime 0.6.3可作为排查版本兼容问题的参考。下一步探索完整向导文档website/docs/agents/walkthrough.md其中包含本文未逐字展开的 UI 截图与逐行演进说明。运行时 API 的实现细节可深入源码createEntityRegistry见 packages/agents-runtime/src/define-entity.tscreateRuntimeHandler见 packages/agents-runtime/src/create-handler.tspassthrough见 packages/agents-runtime/src/entity-schema.ts。更多通信拓扑与编排模式可参考仓库中的 agents-playground 示例 与 agents-chat-starter 示例。对照src/index0.ts到src/index5.ts逐份阅读是最快的内化方式——每一份文件都对应向导中的一个里程碑最终形态就藏在src/index.ts的混合控制流里。【免费下载链接】electricThe agent platform built on sync.项目地址: https://gitcode.com/GitHub_Trending/el/electric创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考