lume-a2a 实战:任意 lume 家族应用都能接入 A2A

作者:

在

🇬🇧 English

先摆一组数字:起点是两个真实运行的后端节点(crm、scanner),后来默认 fleet 长成 7 个节点、6 个控制台页面、5 个对外能力。

关键在于:这套结构从一开始就不是为「那两个起步应用」量身定的,而是为「任意 lume 家族应用都能接入 A2A」设计的——任何 lume 应用只要原生暴露 Agent Card 与 /a2a,就能照同一套三层结构并排接入;从 2 个涨到 7 个,加的只是目录里的条目,没有推倒重来。

这些数字背后是同一个问题——把真实运行的 lume 应用并排放在一起时,控制台该怎么组织?我花了不短的时间在这上面,最后收敛成一张三层结构:lume 作服务端、lume-core 提供语言与 lib/peer.lume、React 控制台直接对接应用。这篇文章按时间顺序记录我为什么这么切,以及后来它长成了什么样子。

先有痛点,再谈架构:两个应用并存时的三个麻烦

一开始我只是想跑通一个应用。lume 是服务端,lume-core 是语言核心加 lib/peer.lume,两者组合能起一个真正在跑的应用。问题出在第二个应用接进来之后:

第一,端口和进程的对应关系会乱。两个应用各自占一个端口,如果没有一个明确的入口契约,前端很容易对着错的地址发请求。

第二,控制台的交互逻辑会分叉。每个应用有自己的能力集,如果用一套笼统的 UI 去套,最后每个页面都塞满条件分支。

第三,也是我最在意的一点:语言层如果分裂,前面两条都是次要的。所以我先在 lume-core 里确定 lib/peer.lume 作为 peer 侧的共同表达,再往上搭。

架构漫游·第一层:lume 作为服务端

我把 lume 定位成服务端。它不关心控制台长什么样,负责的是把应用跑起来、把端口暴露出去。这是整个系统的地基——所有节点都以 lume 为宿主,所以"多个应用"并不是多套技术栈,而是同一套宿主上加了多份应用定义。

这个取舍有个直接好处:前端的对接面可以统一。React 控制台面对的是"一个正在运行的应用 + 它的地址",而不是"某一种特定实现的应用"。

架构漫游·第二层:lume-core 与 lib/peer.lume

lume-core 是我在整个项目里最看重的一层,因为它承载语言和 lib/peer.lume。为什么单独强调 lib/peer.lume?因为"节点们能互相对话"这件事,本质上是 peer 之间的约定问题。如果每个应用自己造一套通信语义,控制台就没法用一套视图去呈现多组交互。

我把 peer 侧的表达收敛到 lib/peer.lume 这一个点:签名、验签、单次请求,全部复用这一份,一个字不改。两份实现必然漂移,而漂移的表现是"一边通过、一边 401"——这个坑我见过太多次了。

我在这一步踩过的坑是:曾经试图把控制台的表现层逻辑塞进语言层,结果两边都变重。后来我把边界划清——lib/peer.lume 只表达 peer 语义,视图归 React 控制台。

架构漫游·第三层:React 控制台直接对接真实应用

React 控制台的位置是"直接对接真实运行的节点"。注意"直接"两个字:中间没有额外的网关或代理层,控制台拿着节点的地址直接对话。跨节点调用只有一个入口(fleet.invoke),所以"Agent 过程"页能看到它自己决定发起的每一次调用。

这带来了一个必须正视的取舍:控制台需要同时维护多套连接状态。我的处理方式是把"连接哪个节点"和"如何展示"拆开——连接层只认地址和节点标识,视图层根据当前选中的页面渲染对应能力。现在控制台有 6 个页面,各管一个问题(src/fleet.lume:139 的 fleet.invoke 是跨节点唯一签名入口,连接状态按节点分桶维护):

页面 回答的问题
问 Agent 一句话跨节点提问,它自己决定查哪几个节点
客户经营 今天该联系哪个行业的客户、由头
实体对齐 用 USCC 做实体主键,当前覆盖多少、缺口在哪
A2A 协商 两个 agent 各持约束,怎么谈出一个可行解
Agent 过程 节点之间实际发生了什么(埋点流水,含失败/空手而归)
任务 派出去的委派任务到哪一步了:working 的先查执行器再落终态、可取消

之所以坚持"直接",是因为我知道本项目的目标是让这套结构在真实应用上可操作,而不是堆一层抽象去假装统一。真实的节点、真实的端口、真实的 peer 往来,控制台才能验证这套三层划分是否站得住(跨节点调用只有 src/fleet.lume:139 这一个入口,没有中间层可以藏逻辑)。

从两个到七个:fleet 的长大

先说清楚一个前提:这 7 个节点之间没有业务上的强关联——crm 是客户经营、scanner 看行业动向、tsm-hub 盯 LLM 网关、cicdkit 管 CI/CD、lan 看局域网、talentlens 挖人才信号、resolve-lume 跑 PSE 编排,彼此是不同领域、不同来源的独立 lume 应用。它们能并排放在一个 fleet 里,唯一的原因是它们都是 lume 应用——其中 5 个原生暴露完整 A2A 端点,tsm-hub/cicdkit 先以签名 peer HTTP 接入(A2A 端点在补齐)。协议层按同一套约定聚合它们,而不是把它们捏成一个系统。

起点确实是 crm 和 scanner 两个:它们恰好能凑成"今天该联系哪个行业"这一条客户经营线索(scanner 给行业动向,crm 给"该行业有几个客户在跟进"),所以先被我接进来跑通。但这是两个应用的巧合互补,不是什么"业务主线"——后面几个节点根本不在这个线索里,纯粹是因为它们也是 lume 应用、也说 A2A,就照同一套结构接进来了。

后来默认 fleet 长到了 7 个节点,每个各管一摊、互不假设对方存在:

节点 角色
crm / scanner 业务主线(客户经营),签名直连
tsm-hub 只读快照:LLM 网关健康/用量
cicdkit 只读快照:CI/CD 知识/部署
lan 只读快照:局域网在线/流量/DNS
talentlens 只读快照:GitHub 人才信号
resolve-lume 能力目录(resolve.*)+ 执行器(exec.*)

值得单独强调一件事:这 7 个节点里,大多数本身也就是 A2A 对等端,不是单纯"被 a2a hub 拉一次"的只读源。每个 lume 应用(crm、scanner、lan、talentlens、blog)的 peer_node.lume 都原生暴露 /.well-known/agent-card.json 和 POST /a2a,全部复用同一份 lume-core/lib/peer.lume 签名。区别只在"能力有多重",且 Agent Card 里的 agent_backed 字段如实反映这一点:crm 没有 LLM 推理面,写死 false(message/send 走的是"按关键词挑一个只读工具",不是模型作答);scanner 的 agent_backed 由 agent 探活动态决定;lan 背后有真正的推理 agent,写的是 true;talentlens 是静态能力面(手搓的卡,只声明工具端点)。tsm-hub/cicdkit 目前主要走签名 HTTP 调用(/peer/invoke),A2A 端点还在补齐;resolve-lume 同时是能力目录和唯一执行器(src/agent.lume:175 的 resolve.tools 拉它的工具目录;src/agent.lume:277 的 registered() 清点的是本节点的 25 个只读工具,而非它的)。a2a hub 做的事是聚合这些对等端的卡与端点成一个 fleet,控制台面对的是"7 个统一纳管的 fleet 节点——其中 5 个已是完整 A2A 对等端、其余经签名 peer HTTP 接入",不是"7 个只读数据源"。

节点多了,"每个节点暴露什么"就成了纪律问题。现在的做法是能力目录单一事实源(catalog.lume):前端面板、Agent Card、派发三处共用同一份,不会出现"card 里说有、点进去 404"的口径不一致。对外声明 5 个能力(sector.movement / customer.reach / entity.resolve / task.delegate / node.capabilities),Agent 面注册 25 个工具,全部是无参、只读摘要——节点侧先把字段裁干净(不含参数值、不含凭据),a2a 原样透传。

A2A 协议面:agent-card、message/send 与任务生命周期

上面讲的是"几个节点",这一节讲"节点之间到底怎么说话"。A2A 面只由三样东西拼起来,全部基于 A2A v1.0 规范、JSON-RPC 绑定:

1. 能力发现——GET /.well-known/agent-card.json。 对端想找一个节点能干什么,规范入口是这张卡,不用靠"知道端口"这种带外信息。卡由 src/a2a.lume 的 card() 生成,skills 字段直接取自 catalog.lume,所以"卡里说有、点进去 404"这种口径不一致在结构上就被防住了。按 A2A 规范,agent-card 是公开未认证端点(它本来就是给人/客户端探路的)。一个容易踩的细节:节点绑 0.0.0.0 但没设 PEER_PUBLIC_URL 时,卡里宣告的 url 会写成 127.0.0.1,跨机客户端直接打不进来——card() 里这种情况会往卡里写一条 warnings,不是静默吞掉。

2. 消息通道——POST /a2a,JSON-RPC message/send。 请求带 params.message.parts(文本)、可选 contextId;响应/任务里带 status.state。同一 taskId 再发一次 message/send 就是"接着这个任务说"——这是多轮续发的规范语义(比如第一轮回了 input-required,客户端补上参数再发一次,任务原地推进而不是新建)。任务生命周期按 A2A v1.0 分两类:completed/failed/canceled/rejected 是终态,input-required/auth-required 是中断态——规范对 input-required 的原话是 "This is an interrupted state":任务暂停等输入,同一 taskId 补上参数再发一次就能原地续。有一处实现偏差要如实交代:src/a2a.lume:170 的 STATE_DONE 把 input-required 也收进了 done 集合,比规范收得紧(把它当"不再自动推进"处理),做续发交互时要心里有数。a2a 是任务态的唯一写者(这一条在下一节 task.delegate 里会再收紧)。数据节点(crm/scanner 等)还实现了 message/stream:同步执行完,把 working → artifact → completed 三条事件一次性回放成 SSE,不是真·流式进度——Agent Card 里 streaming: true 指的是 SSE 事件通道符合规范(node.lume:504 的注释把"回放 vs 实时"写得很直白),不代表有边跑边推的增量进度。

3. 签名——所有 peer 调用过 lib/peer.lume 一份。 签名串是 node\n ts\n nonce\n scope,支持 HMAC 共享密钥 / 派生密钥 / Ed25519 非对称三种模式(优先级 ed25519 > derived > shared),防重放是时间窗级别。这份实现全家族只此一份,姊妹应用(crm/lan/scanner/talentlens/blog)的 peer_node.lume 与 a2a 的 node.lume 都 import 它,谁也不许自己再写一份验签逻辑。

这里要把两层安全边界分开说,避免误读:A2A v1.0 的 JSON-RPC 端点(POST /a2a)本身不带认证——本实现也如实接受明文调用(node.lume:490 的路由没有验签),演示与内网场景够用,上公网要另加传输层认证;真正带 HMAC/Ed25519 签名的是 lume 自有的 peer 通道(/peer/invoke 与 relay),这是项目自己的安全机制,不是 A2A 标准规定的认证。密钥不进浏览器:控制台源码把原因写得很直白(src/ui/api.ts:97——"把 PEER_SHARED 放到前端等于把密钥发给每个访问者"),浏览器只发业务参数,签名由服务端代签(/api/local-invoke,node.lume:376,target 只收同源、防它变成开放代理)或走 relay;节点之间的互调则全部过签名。

task.delegate:查询侧借执行侧的手

节点长到 7 个之后,一个自然的问题是:控制台能不能顺手把"动手"的活也办了?我的答案是能委派,但不能亲手干,并把它固化成一条纪律(src/a2a.lume:315 的 cap_delegate 本节点不自己执行,只把 job 经签名 peer 通道递给 executor):

a2a 是查询工作台,resolve-lume 是执行工作台。

具体链路:

task.delegate (a2a 收到)
  → fleet 签名调用 resolve-lume 的 exec.submit
  → sidecar 后台起 job(真 spawn/exec 在这一层) → 返回 job_id
  → a2a 任务停在 working, 只记 {executor, job_id}
  → tasks/get 时懒推进: 查 exec.status, 落 completed/failed/canceled

三条纪律:

  1. a2a 是任务态唯一写者——执行器只当签名代理,不回调、不写 a2a 的任务文件(src/a2a.lume:349 的 observe_delegate 是唯一落终态处,executor 不回调);
  2. 懒推进、无常驻 poller——没有后台进程在等,tasks/get 时顺路推一步(旧的宿主轮询脚本已归档为死代码);
  3. 不回退——执行器不可达就明确报 input-required,绝不悄悄改由 a2a 自己跑。严格说这借用了规范的"中断态":缺 prompt 参数是真·input-required(src/a2a.lume:320,标准语义——补上参数就能续);executor 不可达也映射成它(:333)是保守选择,语义上更接近"需要调用方介入(上线执行器/换目标/重试)"而非"补个参数就行"——对照懒推进阶段对暂时不可达保持 working(见下条),两种场景没有混用。

为什么划这条线?因为一旦有人往 a2a 装"手"(注册写工具、放行写端点),它就从查询工作台退化成杂物箱,而那一刻不会有任何报错提醒你——边界是靠纪律撑的,不是代码里的开关。resolve-lume 本身就是个现成例子:它是全家桶里唯一会动手的重型节点(PSE 编排 + 真·宿主工具层),但接入 a2a 时只肯亮"菜单"(skills/tools 目录,不含参数值),不亮"手"。

还有一条数据流要交代清楚:task.delegate 的终态里,answer 是执行器的输出。哪天委派的活会读宿主数据,那个 answer 就可能带敏感内容并被 a2a 落盘——所以委托前要对"被委托的活本身"做脱敏判断,别把"读宿主真实文件/凭据"这类活随手 delegate。

复核:这条决策线是否成立

回头看这几层划分,判断标准只有一条:每一层是否只管一件事。

  • lume 作为宿主负责运行;lume-core 的 lib/peer.lume 负责 peer 语义;React 控制台负责表现与连接——三层各管一件事,节点从 2 长到 7 没有推倒重来,加的只是目录里的条目和角色说明;
  • 查询与执行的分界(task.delegate)让"控制台"始终停在查询侧,动手永远留在执行工作台——节点再多,这条线也不会糊;

如果让我重来,我会更早把 lib/peer.lume 的边界画清楚,而不是等到两个应用都接上之后再回头收;也会更早把"查询侧/执行侧"这条线写进纪律,而不是等 fleet 长到 7 个节点才补。这是我在这个项目里最真实的一条经验。

源码实证(节选)

协商不是“一方提一方接”,而是两方各持约束、带理由的否决——这是 Multi-Agent 协作里最容易被写成“单边排序”的地方。src/negotiate.lume:76 的 customer_veto 把每条约束都要求带 reason,否则就退化成可随手绕过的开关:

func customer_veto(prop, ctx) {
  let sign = get(prop, "signaled", false);
  if (sign != true) {
    return { veto: true, code: "NO_SIGNAL",
             reason: "该标的当日无扫描信号, 无法给客户一个有依据的推荐理由" };
  }
  let r = graph.resolve("", "", str(get(prop, "code", "")));
  if (str(get(r, "status", "")) != "matched") {
    return { veto: true, code: "UNALIGNED_ENTITY",
             reason: "该标的未在实体索引中确认(USCC/代码), 无法确认对应哪家工商主体" };
  }
  let ch = float(get(prop, "change", 0.0));
  if (ch < -5.0) {
    return { veto: true, code: "TODAY_DROP",
             reason: "当日跌幅 " + str(ch) + "%, 不适合作为当日推荐开场" };
  }
  return { veto: false, code: "", reason: "" };
}

a2a 协议面实证:card()、cap_delegate、observe_delegate

协商那段讲的是"两方各持约束",而 a2a 协议面的三块代码才是这套系统真正区别于"又一个 REST 壳"的地方——发现、委派、任务态三件事都收敛在 src/a2a.lume 一处。

Agent Card 是生成的,不是手填的。 card()(src/a2a.lume:240)的 skills 直接取自 catalog.as_skills(),所以"卡里说有、点进去 404"在结构上就被防住;节点绑 0.0.0.0 却没设 PEER_PUBLIC_URL 时,它往卡里写一条 warnings 而不是静默吞掉:

export func card() {
  let base = base_url();
  let warn = "";
  if (self_bind == "0.0.0.0" and len(public_url) == 0) {
    warn = "节点绑定 0.0.0.0 但未设 PEER_PUBLIC_URL: card 宣告 " + base +
           ", 跨机客户端请把 PEER_PUBLIC_URL 设成本机可达地址";
  }
  return {
    name: node_name(),
    description: "Lume A2A 演示节点: 展示 peer 之间如何发现(Agent Card)、" +
                 "签名调用(message/send)、多跳转发与任务生命周期。" +
                 "只读能力(sector/customer/entity)不下探个股; task.delegate 把要动手的活派给执行工作台, 本节点不自己执行。",
    version: "1.0.0",
    protocolVersion: "1.0",
    supportedInterfaces: [
      { protocolVersion: "1.0", protocolBinding: "JSONRPC", url: base + "/a2a" },
    ],
    url: base + "/a2a",
    capabilities: { streaming: true, pushNotifications: false, agent_backed: false },
    defaultInputModes: ["text/plain", "application/json"],
    defaultOutputModes: ["text/plain", "application/json"],
    skills: catalog.as_skills(),
    warnings: warn,
  };
}

task.delegate 的"a2a 不自己动手"是代码事实,不是口号。 cap_delegate(src/a2a.lume:315)本节点没有任何 spawn/exec,只把 job 经签名 peer 通道 fleet.invoke_args(target, "exec.submit", ...) 递给 executor;executor 不可达就老老实实回 input-required,绝不悄悄改由本节点自己跑——那会让 a2a 突然长出执行面:

func cap_delegate(args) {
  let target = str(get(args, "executor", ""));
  if (len(target) == 0) { target = "resolve-lume"; }
  let prompt = str(get(args, "prompt", ""));
  if (len(prompt) == 0) {
    return { state: "input-required", artifacts: [],
             note: "task.delegate 需要 prompt(交给执行工作台的指令)" };
  }
  let payload = { prompt: prompt };
  let tid = str(get(args, "taskId", ""));
  if (len(tid) > 0) { payload = put(payload, "taskId", tid); }
  let model = str(get(args, "model", ""));
  if (len(model) > 0) { payload = put(payload, "model", model); }
  let t0 = int(now());
  let r = fleet.invoke_args(target, "exec.submit", payload, 30);
  let ms = (int(now()) - t0) * 1000;
  if (!get(r, "ok", false)) {
    return { state: "input-required", artifacts: [],
             note: "executor '" + target + "' 不可达: " + str(get(r, "error", "")) +
                   "(需该节点在线且其 sidecar 已启动)", job: null };
  }
  let j = get(r, "result", {});
  let job_id = str(get(j, "job_id", str(get(j, "id", ""))));
  return { state: "working", artifacts: [],
           note: "已派给 executor '" + target + "' (job_id=" + job_id + ", " + str(ms) + "ms)",
           job: { executor: target, job_id: job_id,
                  submitted_at: int(now()), prompt: prompt } };
}

a2a 是任务态唯一写者、executor 不回调,靠的是 observe_delegate 的懒推进(src/a2a.lume:349):tasks/get 时才查 executor 的 job 态、落 completed/failed/canceled;executor 暂时不可达就保持 working 等下次,而不是记成 failed:

export func observe_delegate(id: string) {
  let t = load(id);
  if (t == null) { return null; }
  if (str(get(get(t, "status", {}), "state", "")) != "working") { return t; }
  let payload = get(get(t, "metadata", {}), "payload", {});
  let exec = str(get(payload, "executor", ""));
  let job_id = str(get(payload, "job_id", ""));
  if (len(exec) == 0 or len(job_id) == 0) { return t; }
  let r = fleet.invoke_args(exec, "exec.status", { job_id: job_id }, 5);
  if (!get(r, "ok", false)) {
    return t;  // executor 暂时不可达: 保持 working, 下次 tasks/get 再试(不记成 failed)
  }
  let job = get(get(r, "result", {}), "job", get(r, "result", {}));
  let jstat = str(get(job, "status", ""));
  if (jstat == "succeeded") {
    let ans = str(get(job, "answer", ""));
    return advance(id, "completed", [artifact(ans)], "executor succeeded (job " + job_id + ")");
  }
  if (jstat == "failed") {
    return advance(id, "failed", [], "executor failed (job " + job_id + ")");
  }
  if (jstat == "cancelled" or jstat == "canceled") {
    return advance(id, "canceled", [], "executor canceled (job " + job_id + ")");
  }
  return t;  // running: 保持 working
}

要现场复现这套协商与舰队校验,直接跑两个 Makefile target(src/agent.lume:277 的 registered() 也是其中之一的数据来源):

make fleet-verify    # 逐节点断言:在线/签名/真实字段/白名单/现场比对
make llm-negotiate   # 跑一次多轮协商,结果写入 data/self/llm-negotiation.json

源码导航

  • node.lume(服务端入口:路由 + A2A JSON-RPC + 启动自检)
  • src/a2a.lume(任务状态机 / Agent Card / dispatch,三条链路的唯一实现)
  • src/fleet.lume(跨节点调用唯一入口 + trace 埋点)
  • src/catalog.lume(能力目录,前端/Agent Card/派发 三处同源)
  • src/agent.lume(25 个只读工具注册,纯 lume)
  • lume-core/lib/peer.lume(peer 侧语义)
  • src/ui/(React 控制台,esbuild 打包到 www/,服务端直发)
  • docs/architecture.md(架构与模块分工)

说明:以上为目录级导航,实际文件名以代码仓库 erishen/lume-a2a 为准。

lume-a2a 由哪几部分组成?

三层——lume(服务端宿主)、lume-core(语言与 lib/peer.lume)、React 控制台。跨节点只有一个调用入口(fleet.invoke),peer 语义全收敛在 lib/peer.lume 一份。

现在默认接几个节点?

7 个:crm / scanner(业务主线)+ tsm-hub / cicdkit / lan / talentlens(只读快照)+ resolve-lume(能力目录 + 执行器)。PEER_FLEET 可覆盖。

React 控制台是怎么对接节点的?

直接调用,中间不追加额外网关层。跨节点调用统一走签名通道,控制台能看见每一次调用(”Agent 过程”页)。

task.delegate 是干什么的?

把”动手”的活派给执行器(默认 resolve-lume 的 sidecar)。a2a 只记任务态、做签名代理,不动手;tasks/get 时懒推进查执行器结果。边界是纪律撑的:写工具不进 a2a 的 Agent 面。

为什么要把语言层和表现层分开?

把 peer 语义收敛在 lib/peer.lume,视图归 React 控制台,避免两层互相拖累。两份签名实现必然漂移,漂移的表现是一边通过、一边 401。

那些接入的应用本身也支持 A2A 吗?

支持。crm / scanner / lan / talentlens / blog 的 peer_node.lume 都原生暴露 /.well-known/agent-card.json 与 POST /a2a,全部复用同一份 lib/peer.lume 签名。能力轻重各节点如实自报:crm 写死 agent_backed: false(无 LLM 推理面),lan 背后有真 agent、写 true,scanner 按探活动态定。a2a hub 做的是把它们的卡与端点聚合成一个 fleet。

项目的 GitHub 地址是什么?

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

商店 Web Chat Nsbp 关于 隐私政策

@ 2026 ESN
沪ICP备2024079226号-1   沪公网安备31010502007082号