# 内部上下文查询接口规范(Internal Context Query API Spec) **版本**:v1.1 **最后更新**:2026-08-13 **对应实现**:`backend/src/main/java/com/agent/management/engine/NodeContextQueryService.java` --- ## 1. 概述 ### 1.1 设计目标 为工作流中每个节点的 LLM 调用提供"按需查询前驱节点信息"的能力: | 查询类型 | 工具名(Python 端) | 用途 | |---|---|---| | 路径图 | `get_predecessor_paths` | 当前 visit 的所有前驱访问链(含智能回边) | | 输出查询 | `get_node_output` | 某个前驱节点的 envelope.data 或单字段 | | 中间数据 | `get_node_meta` | 某个前驱节点的思考过程 / 工具调用 / TODO 列表 | ### 1.2 核心约束 - **快**:路径图与 visit 列表查询命中内存索引,亚毫秒级 - **准**:按 sortOrder 严格有序,visitUuid 单点精确路由 - **稳**:内存优先(运行中 + 结束后 30 分钟),DB 回退兜底 - **安全**:一次性 token 鉴权 + runId 强校验,token 与 visitUuid 绑定 ### 1.3 与 External Workflow API 的区别 | 维度 | External API (`/api/v1/...`) | Internal Context Query (`/internal/context/...`) | |---|---|---| | 调用方 | 业务方前端 / 第三方集成 | Hermes Bridge 子进程内的 LLM 工具调用 | | 鉴权 | /api/v1 Token(业务方提供) | 一次性 token(Java 自动生成) | | 路径前缀 | `/api/v1/...` | `/internal/context/...` | | 用途 | 业务方观察 / 控制运行 | LLM 在节点执行时按需查询前驱上下文 | --- ## 2. 接口列表 所有接口均为 **POST** 方法,请求体为 JSON,响应体统一为 `Result` 结构: ```json { "code": 200, "message": "success", "data": } ``` 错误码:`401`(token 无效或过期)、`403`(runId 不匹配)、`404`(visit 未找到)、`503`(功能已禁用)。 ### 2.1 POST /internal/context/path **用途**:查询当前 visit 的所有前驱访问链。 **请求体**: ```json { "runId": "abc12345", "visitUuid": "550e8400-e29b-41d4-a716-446655440000" } ``` **响应**: ```json { "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 第二次访问): ```json { "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"] } } ``` ### 2.2 POST /internal/context/output **用途**:查询某个前驱节点的输出(envelope.data 整包或单字段)。 **请求体**: ```json { "runId": "abc12345", "nodeId": "B", "field": null, "iterSeq": null } ``` 字段说明: - `field`:为 `null` 返回整个 data;指定字段名返回单字段值 - `iterSeq`:为 `null` 取最后一次访问;指定 `1/2/3` 取对应访问 **响应**(envelope 形式): ```json { "data": { "nodeId": "B", "iterSeq": 1, "visitUuid": "v2", "status": 200, "message": "调用成功", "data": { "result": "hello", "count": 10 }, "requestedField": null } } ``` **响应**(单字段): ```json { "data": { "nodeId": "B", "iterSeq": 1, "visitUuid": "v2", "status": 200, "message": "调用成功", "data": "hello", "requestedField": "result" } } ``` ### 2.3 POST /internal/context/meta **用途**:查询某个前驱节点的中间数据(思考过程 / 工具调用 / TODO)。 **请求体**: ```json { "runId": "abc12345", "visitUuid": null, "nodeId": "B", "kind": "THINKING", "iterSeq": null } ``` 字段说明: - `visitUuid`:非空时优先使用,忽略 `nodeId` - `nodeId`:visitUuid 为空时使用 - `kind`:`THINKING` / `TOOL_CALLS` / `TODO` / `ALL`;为空按 `ALL` 处理 - `iterSeq`:同 output 接口 **响应**(THINKING): ```json { "data": { "nodeId": "B", "iterSeq": 1, "visitUuid": "v2", "thinking": "第一步\n第二步" } } ``` **响应**(TOOL_CALLS): ```json { "data": { "nodeId": "B", "iterSeq": 1, "visitUuid": "v2", "toolCalls": [ {"tool": "search", "args": "{q:1}"}, {"tool": "search", "result": "结果摘要"}, {"tool": "calc", "args": "{x:2}"} ] } } ``` **响应**(TODO,取最后一次 TODO_UPDATE): ```json { "data": { "nodeId": "B", "iterSeq": 1, "visitUuid": "v2", "todoList": [ {"text": "任务A", "status": "completed"}, {"text": "任务B", "status": "pending"} ] } } ``` **响应**(ALL,含原始 logs): ```json { "data": { "nodeId": "B", "iterSeq": 1, "visitUuid": "v2", "thinking": "...", "toolCalls": [...], "todoList": [...], "logs": [ {"type": "THINKING", "timestamp": "...", "message": "...", "detail": null}, {"type": "TOOL_CALL", "timestamp": "...", "message": "...", "detail": "..."} ] } } ``` --- ## 3. Token 鉴权 ### 3.1 生命周期 ``` [节点执行开始] ↓ HermesAgentExecutor.execute 调用 InternalContextTokenService.generateToken(runId, visitUuid) ↓ token 通过 HermesBridgeClient.run 的 context_query 字段传给 Python ↓ LLM 在一次 visit 内多次工具调用复用同一 token(TTL 5 分钟) ↓ [节点执行结束(成功 / 失败)] ↓ InternalContextTokenService.invalidate(token) 立即失效 ``` ### 3.2 请求头 ``` X-Context-Token: <48 字符 hex 字符串> ``` ### 3.3 安全语义 - token 不参与 URL,仅放 Header,避免日志泄露 - token 与 visitUuid 绑定,跨 visit 不可复用 - 同一 runId+visitUuid 同时存活 token 上限 4(防御 bug) - TTL 5 分钟,超时自动清理 - runId 必须与 token 关联的 runId 一致,否则返回 403 --- ## 4. 配置项 `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 上限 | ### 4.1 降级开关语义 `hermes.context-query.enabled=false` 时: 1. **Java 端**:`/internal/context/**` 三个接口一律返回 503 2. **Bridge 协议**:HermesAgentExecutor 不生成 token,HermesBridgeClient 不在 body 加 `context_query` 字段,Prompt 中不注入工具说明 3. **Python 端**:未收到 `context_query` 字段时不注册 3 个工具,LLM 不会感知到该能力 降级后工作流执行不受影响,仅 LLM 失去"按需查询前驱上下文"的能力。 --- ## 5. Python 端实现指引 Python hermes_bridge.py 需要在 `/run` 接口接收 `context_query` 字段,并注册 3 个工具。 ### 5.1 RunRequest 扩展 ```python class RunRequest(BaseModel): # ... 现有字段 ... context_query: Optional[dict] = None # {base_url, token, run_id, visit_uuid} ``` ### 5.2 工具注册(伪代码) ```python 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) ``` ### 5.3 在 run_agent 中调用 ```python @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) # ... 后续逻辑 ... ``` --- ## 6. 性能基线 | 查询场景 | 延迟(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 解析 | --- ## 7. 引擎集成点 | 引擎类 | 集成内容 | |---|---| | `AgenticExecutor` | scheduleNode 入口:`visitIndex.register` + `tokenService.generateToken` | | `WorkflowLevelExecutor` | 同上(DAG 模式) | | `HermesAgentExecutor` / `HermesSmartActionExecutor` | 调 `HermesBridgeClient.run` 时构造 `ContextQueryConfig` | | `HermesBridgeClient` | body 加 `context_query`,Prompt 加工具说明(已完成) | | `NodeContextVisitIndex` | `appendLog` 在 SSE 事件回调中触发;`appendOutput` 在节点完成时触发;`markRunFinished` 在运行结束时触发 | --- ## 8. 相关文档 - 实施计划:`docs/plans/node-context-query-tool-plan.md` - External API 规范:`docs/specs/external-workflow-api-spec.md` - AGENTIC 工作流计划:`docs/plans/agentic-workflow-plan.md` --- ## 9. 安全注意事项(Python Bridge 端) > 本节面向 Hermes Bridge 子进程的维护者。token 在 Java 侧已通过短 TTL + 显式 invalidate + per-visit 限流 三重防护, > 但 Python 端如果使用不当仍可能成为泄漏点。以下为强制约束。 ### 9.1 Token 存储 - **禁止写入磁盘**:token 不得写入日志文件、Hermes Home 目录、调试 dump 文件等任何持久化媒介。 - **进程内存生命周期**:token 仅保存在工具调用的闭包/参数中,工具返回后立即让 GC 回收,**不要放进模块级全局变量**。 - **禁止打印**:异常 traceback、debug 打印等一律不得输出 token 原文,需要时仅打印前 4 字符 + `***`。 ### 9.2 HTTP 请求构造 - **仅允许 localhost**:base URL 由 Java 侧通过 `context_query.base_url` 传入,Python 端**不得修改**。 Bridge 启动时若发现 base_url 指向非 127.0.0.1/localhost,应拒绝注册工具并记录告警。 - **强制 HTTPS 头校验**:响应中若出现 `Set-Cookie`、`Location` 等敏感头,工具应直接丢弃响应并返回错误。 - **超时硬上限**:单次请求超时 ≤ 5s,避免 LLM 工具调用长时间挂起。 - **重试限制**:失败后不重试或最多 1 次(指数退避),避免被诱导产生大量请求。 ### 9.3 工具响应处理 - **token 不回显**:响应体不得包含 token 字段。Java 侧返回的 `runId`、`visitUuid` 也仅作为调试上下文短时使用, 不得在 LLM 可见 prompt 中重复输出(避免被 Prompt Injection 窃取)。 - **输出长度限制**:单字段返回值长度上限 100KB,超过时截断并附 `truncated: true` 标记,防止恶意节点构造超大输出 导致 LLM 上下文爆炸。 - **路径数截断感知**:`get_predecessor_paths` 返回的 `paths` 数组若达到 100 条上限,工具描述应提示 「结果可能被截断」,引导 LLM 用更具体的 `nodeId` 重查。 ### 9.4 Prompt Injection 防御 - **查询返回内容仅作为 LLM 输入**:不直接拼接到 system prompt,也不作为代码执行(如 `eval`、`exec`)。 - **JSON 字段白名单**:解析返回 JSON 时仅读取预定义字段(如 `data.output`、`data.thinking`), 其余字段一律忽略,避免恶意前驱节点通过特殊字段名触发 Bridge 端异常行为。 - **工具调用频率监控**:Bridge 应记录单次 visit 的工具调用次数,超过 50 次(可调)时拒绝后续调用并返回错误, 防御恶意 LLM 通过高频查询拖垮 Java 侧。 ### 9.5 Java 侧防线(已实现,供 Python 端对照参考) | 防线 | 实现 | 说明 | |---|---|---| | 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 导致栈溢出 | --- ## 10. 变更历史 | 版本 | 日期 | 变更 | |---|---|---| | v1.1 | 2026-08-13 | 初始版本:三类查询接口 + token 鉴权 + 内存优先/DB 回退 | | v1.1.1 | 2026-08-13 | 安全加固:TTL 120s、VisitIndex 内存上限、路径深度限制、Python 端安全章节 |