门面 Agent 与 AgentRuntime
门面 Agent 是平台托管的一类:没有外部端点、由
AgentRuntime执行。 外部 Agent(供应商自有运行时)的接入见 外部 Agent 接入。
门面 Agent 是需求入口:它可以对话、可以登记需求、可以在本地完成交付,但不把对话原文写进状态层。
1. 定义与准入
判定(TaskExecutionService.isPlatformHostedFacade / AgentDialogueService.resolveSessionRuntime):
AgentProfile.status = active;- 没有
a2aEndpoint; - 没有
gatewayUrl/dialogue.endpoint.url(没有可推送的 socket 客户端); - 通常带
stateContract。
delivery 决定它能不能承诺结果:
| 模式 | 含义 | 谁执行 |
|---|---|---|
local-skill | 有 skillIds,平台本地产出 | AgentRuntime |
intake-only | 只登记需求,转人工/待招募 | 无执行,返回登记结果 |
supplier-attached | 有 a2aEndpoint | 属于外部 Agent,不是门面 |
2. 三种执行入口
| 入口 | 调用方 | Runtime 方法 | 输入 | 输出 |
|---|---|---|---|---|
| 对话 | AgentDialogueService | runStream() / run() | 截断后的历史 | 对话回复;确认后 submit_request |
| 状态唤醒 | AgentTriggerExecutor → Bull consumer | run() | 事件快照(契约 input schema) | contract.output.path(envelope) |
| 任务节点 | TaskExecutionService.executeLocalFacade | run() | node.config.input | 节点结果(文本/对象) |
三者共用同一个 AgentRuntime,但语义不同:
| 对话 | 状态唤醒 | |
|---|---|---|
| Memory | persistMemory=false,原文不进状态层 | 读/写 task memory |
| 结构化 | 自然语言 + submit_request | JSON envelope + Ajv 校验 |
| 重试 | 用户重发 | 默认不自动重试 |
| 生命周期 | 多轮 session(轮次/TTL/token 预算) | 一次唤醒,跑完释放 |
3. AgentRuntime 能力矩阵
| 能力 | 行为 |
|---|---|
| 无会话 | 一次 run() = 一次唤醒,无跨 run 隐式状态 |
| 预算 | maxSteps / maxTokens / deadlineMs,结构化停止 |
| 工具 | 白名单 registry;extraTools 仅本次可见 |
| Memory | task 级 working memory;对话关闭 |
| 并发 | per-agent(默认 10)+ per-tenant(默认 20),超限 429 |
| 取消 | AbortSignal 中断在途 LLM,半截输出不落库 |
| 流式 | runStream();端口无流时退回 complete() |
| 结构化 | 状态唤醒带 response_format: json_object + Ajv 校验 |
| 观测 | runs/latency/stopReasons/resources + Mongo 采样 |
4. 对话入口(HTTP + SSE)
bash
# 1) 创建/复用 session(runtime=local 由 profile 自动判定)
POST /api/gateway/dialogue/sessions
{ "agentId": "facade-hs-code", "taskId": "469...", "reuseActive": true }
# 2) 非流式一轮
POST /api/gateway/dialogue/sessions/{sessionId}/message
{ "text": "蓝牙耳机出口德国,HS Code 怎么归类?" }
# 3) 流式一轮(start → delta* → done → [result])
POST /api/gateway/dialogue/sessions/{sessionId}/message/stream
{ "text": "...", "waitForResult": true }
# 4) 结果回读(submit_request 之后)
GET /api/gateway/dialogue/sessions/{sessionId}/resultmessage 返回:
json
{
"sessionId": "dialogue_...",
"turnCount": 2,
"response": "……",
"status": "input_required",
"done": false,
"stopReason": "completed",
"tokens": 1234,
"submission": { "requestId": "...", "eventId": "...", "resultPath": "..." },
"history": { "keptMessages": 4, "omittedMessages": 0, "estimatedTokens": 900 }
}SSE 事件
| 事件 | 负载 | 说明 |
|---|---|---|
start | { sessionId, at } | 一轮开始 |
delta | { delta } | 模型 content 流式片段 |
tool_call | { name, ok, durationMs } | 工具轮单独事件 |
done | 轮次结果 + submission | submission 是指针,不是结果 |
result | 状态层结果 + output | 本轮写了状态且下游已写出结果时推送 |
error | { message, status } | 业务错误;客户端断连不发 |
- 断连即取消:连接
close触发AbortSignal,中断在途模型请求;这一轮不落库。 - 心跳:
AGENT_DIALOGUE_SSE_HEARTBEAT_MS(默认 15s)写: ping。 - 结果推送:
done后若存在submission.resultPath,同一连接继续等待并推result;GET .../result是兜底。
确认后交接给状态层
对话(多轮,原文不进状态层)
→ 用户确认
→ submit_request 按契约 input schema 校验
→ 写 /tasks/{taskId}/requests/{sessionId}(含 conversation_id + confirmed_by)
→ 订阅命中 → 下游写 resultPath
→ SSE result / GET .../result 回读边界:没有 taskId 不暴露 submit_request;幂等是「读后写」检查,不是唯一索引。
5. 状态入口(契约 → 写入 → 订阅 → 回写)
ts
// 1) 定义契约(AgentProfile.stateContract)
{
version: 2,
input: { path: '/tasks/{taskId}/requests/{requestId}', schema: { ... } },
output: { path: '/tasks/{taskId}/agents/{agentId}/results/{eventId}', schema: envelope },
trigger:{ pattern: '/tasks/*/requests/*' },
delivery: { mode: 'local-skill' }
}
// 2) 发布订阅(编译契约快照)
await contractCompiler.apply(await contractCompiler.plan(agentId));
// 3) 写请求(批量/异步可选)
await state.write({ path: requestPath, value: { ... }, actor: { type: 'user', id } });
await state.writeMany(inputs, { dispatch: 'async' }); // 只写 outbox执行链:
state_events / state_entries
→ StateTriggerDispatcher.matchEvent()
→ AgentTriggerExecutor.execute()
→ Bull agent-execution(默认)
→ AgentExecutionConsumer
→ AgentRuntime.run({ responseFormat: 'json_object' })
→ parse + Ajv validate
→ write contract.output.path(envelope)
→ 失败写 .../errors/{eventId},job faileddispatch: 'sync'(默认)提交后立即匹配订阅并入队;async只写state_outbox,由 worker 按namespace/seq顺序派发。triggerDepth每次 +1;达到maxTriggerDepth只记事件、不再触发(防环)。- 没有已发布订阅的门面不会被状态唤醒;迁移/改契约后需要重新 apply。
6. 结构化交付(envelope + JSON mode)
门面的状态交付统一为:
json
{
"status": "ok | needs_input | intake | not_applicable",
"summary": "一句话结论",
"data": { "...业务字段..." },
"follow_up": ["..."],
"confidence": 0.72,
"text": "长报告原文(可选)"
}data来自该门面第一个 skill 的output_schema;intake-only 门面允许任意对象。- 状态唤醒 prompt 强制“只返回一个符合 schema 的 JSON 对象”;请求带
response_format: { type: 'json_object' }。 - 供应商边界(实测):DeepSeek 支持
json_object,json_schemastrict 返回 400;字段级约束由平台 Ajv 兜底。 - 解析支持纯 JSON、```json 围栏、前后夹说明文字;失败写
errors/{eventId}。 - 对话模式不启用 JSON mode(需要自然语言与澄清)。
7. 资源边界与错误语义
| 场景 | 表现 |
|---|---|
| per-agent / per-tenant 并发超限 | HTTP 429,code=agent_runtime_busy |
| 单租户活跃对话 session 超限 | HTTP 429,code=dialogue_session_quota_exceeded |
| 空闲超过 TTL | HTTP 410 Session expired |
达到 maxTurns / maxTokensPerSession | HTTP 400 |
| 客户端断连 | 中断在途 LLM,不发 SSE error,这一轮不落库 |
| 预算耗尽 | stopReason = max_steps / max_tokens / deadline |
| 输出不符合契约 | errors/{eventId} + 抛出(Bull failed) |
对话预算(新建 session 时冻结):
AGENT_DIALOGUE_MAX_TURNS=12
AGENT_DIALOGUE_MAX_HISTORY_TOKENS=8000
AGENT_DIALOGUE_IDLE_TTL_MS=1800000
AGENT_DIALOGUE_MAX_TOKENS_PER_SESSION=50000
AGENT_DIALOGUE_MAX_SESSIONS_PER_TENANT=100
AGENT_DIALOGUE_SSE_HEARTBEAT_MS=15000
AGENT_DIALOGUE_RESULT_PUSH_TIMEOUT_MS=30000单轮运行时预算:maxSteps=3、maxTokens=6000、墙钟 60s。
8. 性能(2026-10-23 实测,非 mock)
| 场景 | 结果 |
|---|---|
AgentRuntime.run() 单次(简单 prompt) | 最快 1.08s;同一 prompt 也出现过 9.7s(模型侧波动) |
| 对话一轮(本地门面) | 2.7–3.4s |
状态唤醒 write() sync | 写入 32ms,端到端最快 1.53s |
状态唤醒 writeMany async ×5 | 写入 65ms,端到端 5.0–7.1s |
| 任务节点(本地门面) | 5.5–7.3s(含任务旁路) |
表中不含外部 A2A 供应商的延迟:那由供应商自己的运行时决定(进程冷启动、session、模型排队),属于供应商运维范围,不是
AgentRuntime的指标。
运行时基准(mock LLM,不烧 token):
bash
npm run bench:agent-runtime
BENCH_RUNS=5000 BENCH_CONCURRENCY=200 npm run bench:agent-runtime9. 监控与配置
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /api/agent-runtime/metrics | 实时快照(进程内) |
| GET | /api/agent-runtime/history?minutes=60 | Mongo 历史采样 |
| POST | /api/agent-runtime/benchmark | 内置基准(互斥) |
| POST | /api/agent-runtime/metrics/reset | 清空内存计数 |
| 页面 | /dashboard/agent-runtime | Runtime metrics |
指标:runs/success/failure/inFlight、p50/p95/p99、stopReasons、throughput、heap/RSS、event loop lag。只记录元数据,不落 prompt/工具结果/业务内容。
10. 已知边界
- 配额是进程内计数。PM2 当前单实例 fork,数字准确;多实例严格全局上限需要 Redis 或集中计数。
- outbox worker 只在实例 0 运行;扩多实例前需要 namespace lease 或独立 worker。
- DeepSeek 只支持
json_object,不支持json_schemastrict;模型可能返回needs_input而不是ok。 - 状态唤醒不自动重试:重复执行会重复烧 token。
- 对话原文不进状态层:只写确认后的结构化 request/result。
下一步
- 状态如何叫醒门面、结果如何回写 → 状态如何驱动 Agent
- 外部 Agent 接入 → 外部 Agent 接入
- 状态层与契约 → 状态层
- 能力如何被定义 → Skill 能力目录