版本:v1.1
最后更新:2026-08-13
对应实现:backend/src/main/java/com/agent/management/engine/NodeContextQueryService.java
为工作流中每个节点的 LLM 调用提供"按需查询前驱节点信息"的能力:
| 查询类型 | 工具名(Python 端) | 用途 |
|---|---|---|
| 路径图 | get_predecessor_paths |
当前 visit 的所有前驱访问链(含智能回边) |
| 输出查询 | get_node_output |
某个前驱节点的 envelope.data 或单字段 |
| 中间数据 | get_node_meta |
某个前驱节点的思考过程 / 工具调用 / TODO 列表 |
| 维度 | External API (/api/v1/...) |
Internal Context Query (/internal/context/...) |
|---|---|---|
| 调用方 | 业务方前端 / 第三方集成 | Hermes Bridge 子进程内的 LLM 工具调用 |
| 鉴权 | /api/v1 Token(业务方提供) | 一次性 token(Java 自动生成) |
| 路径前缀 | /api/v1/... |
/internal/context/... |
| 用途 | 业务方观察 / 控制运行 | LLM 在节点执行时按需查询前驱上下文 |
所有接口均为 POST 方法,请求体为 JSON,响应体统一为 Result<T> 结构:
{
"code": 200,
"message": "success",
"data": <T>
}
错误码:401(token 无效或过期)、403(runId 不匹配)、404(visit 未找到)、503(功能已禁用)。
用途:查询当前 visit 的所有前驱访问链。
请求体:
{
"runId": "abc12345",
"visitUuid": "550e8400-e29b-41d4-a716-446655440000"
}
响应:
{
"code": 200,
"message": "success",
"data": {
"visitUuids": ["v1", "v2", "v3", "v4"],
"nodeIds": ["A", "B", "C", "G"],
"paths": [
{
"sequence": ["A", "B", "C", "G"],
"visitUuids": ["v1", "v2", "v3", "v4"],
"hasBacktrack": false
}
],
"backtrackEdgeKeys": []
}
}
含智能回边的响应(G 飞回到 C 第二次访问):
{
"data": {
"visitUuids": ["v1", "v2", "v3", "v4"],
"nodeIds": ["A", "B", "G", "C"],
"paths": [
{
"sequence": ["A", "B", "G", "C"],
"visitUuids": ["v1", "v2", "v3", "v4"],
"hasBacktrack": true
}
],
"backtrackEdgeKeys": ["G->C"]
}
}
用途:查询某个前驱节点的输出(envelope.data 整包或单字段)。
请求体:
{
"runId": "abc12345",
"nodeId": "B",
"field": null,
"iterSeq": null
}
字段说明:
field:为 null 返回整个 data;指定字段名返回单字段值iterSeq:为 null 取最后一次访问;指定 1/2/3 取对应访问响应(envelope 形式):
{
"data": {
"nodeId": "B",
"iterSeq": 1,
"visitUuid": "v2",
"status": 200,
"message": "调用成功",
"data": {
"result": "hello",
"count": 10
},
"requestedField": null
}
}
响应(单字段):
{
"data": {
"nodeId": "B",
"iterSeq": 1,
"visitUuid": "v2",
"status": 200,
"message": "调用成功",
"data": "hello",
"requestedField": "result"
}
}
用途:查询某个前驱节点的中间数据(思考过程 / 工具调用 / TODO)。
请求体:
{
"runId": "abc12345",
"visitUuid": null,
"nodeId": "B",
"kind": "THINKING",
"iterSeq": null
}
字段说明:
visitUuid:非空时优先使用,忽略 nodeIdnodeId:visitUuid 为空时使用kind:THINKING / TOOL_CALLS / TODO / ALL;为空按 ALL 处理iterSeq:同 output 接口响应(THINKING):
{
"data": {
"nodeId": "B",
"iterSeq": 1,
"visitUuid": "v2",
"thinking": "第一步\n第二步"
}
}
响应(TOOL_CALLS):
{
"data": {
"nodeId": "B",
"iterSeq": 1,
"visitUuid": "v2",
"toolCalls": [
{"tool": "search", "args": "{q:1}"},
{"tool": "search", "result": "结果摘要"},
{"tool": "calc", "args": "{x:2}"}
]
}
}
响应(TODO,取最后一次 TODO_UPDATE):
{
"data": {
"nodeId": "B",
"iterSeq": 1,
"visitUuid": "v2",
"todoList": [
{"text": "任务A", "status": "completed"},
{"text": "任务B", "status": "pending"}
]
}
}
响应(ALL,含原始 logs):
{
"data": {
"nodeId": "B",
"iterSeq": 1,
"visitUuid": "v2",
"thinking": "...",
"toolCalls": [...],
"todoList": [...],
"logs": [
{"type": "THINKING", "timestamp": "...", "message": "...", "detail": null},
{"type": "TOOL_CALL", "timestamp": "...", "message": "...", "detail": "..."}
]
}
}
[节点执行开始]
↓
HermesAgentExecutor.execute 调用 InternalContextTokenService.generateToken(runId, visitUuid)
↓
token 通过 HermesBridgeClient.run 的 context_query 字段传给 Python
↓
LLM 在一次 visit 内多次工具调用复用同一 token(TTL 5 分钟)
↓
[节点执行结束(成功 / 失败)]
↓
InternalContextTokenService.invalidate(token) 立即失效
X-Context-Token: <48 字符 hex 字符串>
application.yml 中 hermes.context-query.*:
| 键 | 默认值 | 说明 |
|---|---|---|
enabled |
true |
是否启用内部上下文查询接口 |
token-ttl-seconds |
300 |
token 有效期(秒) |
cleanup-interval-seconds |
60 |
后台清理过期 token 间隔(秒) |
max-tokens-per-visit |
4 |
单次 runId+visitUuid 同时存活 token 上限 |
hermes.context-query.enabled=false 时:
/internal/context/** 三个接口一律返回 503context_query 字段,Prompt 中不注入工具说明context_query 字段时不注册 3 个工具,LLM 不会感知到该能力降级后工作流执行不受影响,仅 LLM 失去"按需查询前驱上下文"的能力。
Python hermes_bridge.py 需要在 /run 接口接收 context_query 字段,并注册 3 个工具。
class RunRequest(BaseModel):
# ... 现有字段 ...
context_query: Optional[dict] = None # {base_url, token, run_id, visit_uuid}
def _register_context_query_tools(agent, context_query: dict):
"""根据 context_query 配置注册 3 个上下文查询工具"""
base_url = context_query["base_url"]
token = context_query["token"]
run_id = context_query["run_id"]
visit_uuid = context_query["visit_uuid"]
headers = {"X-Context-Token": token, "Content-Type": "application/json"}
def get_predecessor_paths() -> dict:
"""获取当前节点的所有前驱访问路径"""
resp = requests.post(
f"{base_url}/internal/context/path",
headers=headers,
json={"runId": run_id, "visitUuid": visit_uuid},
timeout=10,
)
return resp.json()["data"]
def get_node_output(node_id: str, field: Optional[str] = None,
iter_seq: Optional[int] = None) -> dict:
"""获取前驱节点的输出(整包或单字段)"""
resp = requests.post(
f"{base_url}/internal/context/output",
headers=headers,
json={"runId": run_id, "nodeId": node_id,
"field": field, "iterSeq": iter_seq},
timeout=10,
)
return resp.json()["data"]
def get_node_meta(node_id: Optional[str] = None, visit_uuid: Optional[str] = None,
kind: str = "ALL", iter_seq: Optional[int] = None) -> dict:
"""获取前驱节点的中间数据(思考/工具/TODO)"""
resp = requests.post(
f"{base_url}/internal/context/meta",
headers=headers,
json={"runId": run_id, "visitUuid": visit_uuid, "nodeId": node_id,
"kind": kind, "iterSeq": iter_seq},
timeout=10,
)
return resp.json()["data"]
agent.register_tool(get_predecessor_paths)
agent.register_tool(get_node_output)
agent.register_tool(get_node_meta)
@app.post("/run")
async def run_agent(req: RunRequest):
agent = _get_or_create_agent(...)
if req.context_query:
_register_context_query_tools(agent, req.context_query)
# ... 后续逻辑 ...
| 查询场景 | 延迟(p99) | 说明 |
|---|---|---|
| 路径图(内存命中) | < 5ms | 50 个 visit 以内 |
| 路径图(DB 回退) | < 50ms | 索引在 run_id + sort_order |
| 输出查询(内存命中) | < 3ms | 直接 Map.get |
| 输出查询(DB 回退) | < 30ms | visit_uuid unique 索引 |
| 中间数据(ALL,内存) | < 10ms | 含 logs 序列化 |
| 中间数据(DB 回退) | < 50ms | 含 logs_json 解析 |
| 引擎类 | 集成内容 |
|---|---|
AgenticExecutor |
scheduleNode 入口:visitIndex.register + tokenService.generateToken |
WorkflowLevelExecutor |
同上(DAG 模式) |
HermesAgentExecutor / HermesSmartActionExecutor |
调 HermesBridgeClient.run 时构造 ContextQueryConfig |
HermesBridgeClient |
body 加 context_query,Prompt 加工具说明(已完成) |
NodeContextVisitIndex |
appendLog 在 SSE 事件回调中触发;appendOutput 在节点完成时触发;markRunFinished 在运行结束时触发 |
docs/plans/node-context-query-tool-plan.mddocs/specs/external-workflow-api-spec.mddocs/plans/agentic-workflow-plan.md本节面向 Hermes Bridge 子进程的维护者。token 在 Java 侧已通过短 TTL + 显式 invalidate + per-visit 限流 三重防护, 但 Python 端如果使用不当仍可能成为泄漏点。以下为强制约束。
***。context_query.base_url 传入,Python 端不得修改。
Bridge 启动时若发现 base_url 指向非 127.0.0.1/localhost,应拒绝注册工具并记录告警。Set-Cookie、Location 等敏感头,工具应直接丢弃响应并返回错误。runId、visitUuid 也仅作为调试上下文短时使用,
不得在 LLM 可见 prompt 中重复输出(避免被 Prompt Injection 窃取)。truncated: true 标记,防止恶意节点构造超大输出
导致 LLM 上下文爆炸。get_predecessor_paths 返回的 paths 数组若达到 100 条上限,工具描述应提示
「结果可能被截断」,引导 LLM 用更具体的 nodeId 重查。eval、exec)。data.output、data.thinking),
其余字段一律忽略,避免恶意前驱节点通过特殊字段名触发 Bridge 端异常行为。| 防线 | 实现 | 说明 |
|---|---|---|
| Token TTL | hermes.context-query.token-ttl-seconds: 120 |
默认 2 分钟,覆盖单节点常规时长 |
| Token 显式失效 | HermesExecutorSupport.invalidateToken finally 块 |
visit 结束立即移除,不留 TTL 尾巴 |
| Token 限流 | maxTokensPerVisit: 4 |
单 visit 同时存活 token 上限,防御 bug 反复生成 |
| runId 匹配校验 | InternalContextQueryController.authenticate |
token 绑定的 runId 必须与请求体 runId 一致 |
| 接口降级开关 | hermes.context-query.enabled: false |
一键禁用,所有 endpoint 返回 503 |
| 内存索引上限 | NodeContextVisitIndex.MAX_VISITS_PER_RUN = 200 |
防御 Planner 死循环导致内存爆炸 |
| 路径深度上限 | WorkflowPathResolver.MAX_PATH_DEPTH = 200 |
防御极端深 DAG 导致栈溢出 |
| 版本 | 日期 | 变更 |
|---|---|---|
| v1.1 | 2026-08-13 | 初始版本:三类查询接口 + token 鉴权 + 内存优先/DB 回退 |
| v1.1.1 | 2026-08-13 | 安全加固:TTL 120s、VisitIndex 内存上限、路径深度限制、Python 端安全章节 |