AIOps Agent 与 LangGraph

拆解一个生产化 Chat Agent:基于 LangChain 的受控 ReAct 架构

拆解对话入口中的受控 ReAct、工具权限、状态管理与安全收敛。

Chat AgentReActLangChain

用户在同一个对话框里,可能连续提出三类完全不同的请求:

“什么是数据库死锁?”

“查看 incident_xxx 的状态。”

“根据 diagnostic_xxx 的报告执行恢复。”

如果只做一个 Demo,这三句话都可以交给同一个大模型,再把知识库、日志查询、诊断和恢复工具一次性全部注册进去。但进入真实项目后,问题很快就会出现:查询状态为什么还要让模型自由规划?恢复请求为什么能绕过人工确认?工具越来越多后,模型为什么开始选错工具?网络中断后,同一条请求会不会重复执行?

这也是我在实现 Chat Agent 时最先遇到的架构问题:开放式对话需要模型具备动态决策能力,但生产系统不能把路由、权限和副作用一起交给模型。

最终我采用的并不是“所有请求都走 ReAct”,而是一套混合架构:

用户请求
  → 输入安全检查
  → Intent Router
  → Execution Policy
       ├─ direct_read:确定性查询,绕过 ReAct
       ├─ confirmation_required:高风险动作,先生成待确认操作
       └─ bounded_react:开放式任务,进入有预算的 ReAct
  → SSE 公共事件
  → PostgreSQL Run / Event / Audit

本文的中心结论是:

ReAct 负责开放式任务中的动态“思考—行动—观察”循环,LangChain 负责复用模型、消息与工具编排;真正让 Chat Agent 具备生产边界的,是项目自己实现的意图路由、执行策略、最小工具集、预算、人工确认、持久任务和审计。

一、Chat Agent 真正要解决的是什么问题

很多 Chat Agent 的第一版都从下面的循环开始:

User Message → LLM → Tool → Observation → LLM → Answer

这条链路可以回答问题,也可以展示 Tool Calling,但它默认了几个并不成立的前提:

  • 每个请求都需要模型参与;
  • 模型可以看到所有工具;
  • 模型生成的工具参数都可信;
  • 一次 HTTP 连接可以覆盖完整执行周期;
  • 失败后重新执行不会产生副作用;
  • 给用户展示模型输出,就等于完成了审计。

在当前项目中,Chat Agent 不只负责普通对话,还承担了知识问答与 AIOps 系统入口的职责。公开意图被收敛为六类:普通聊天、知识问答、事故查询、启动诊断、诊断状态查询和恢复请求。

这六类请求的风险和执行方式并不相同。知识问答需要动态决定何时检索,事故查询只需要读取已有结构化数据,启动诊断会创建后台任务,而恢复请求必须受人工审批边界约束。

因此,核心问题不是“怎样让模型调用更多工具”,而是:

怎样只在需要模型决策时使用 ReAct,同时让每一次模型调用和工具调用都处于系统可验证的边界内?

二、为什么开放式对话适合 ReAct

固定工作流适合步骤已知、依赖明确的任务。例如“读取诊断报告 → 检查恢复权限 → 生成人工审批请求”,节点顺序在运行前就能确定。

普通对话不同。用户可能问时间,也可能询问某项知识;一次知识问答可能无需检索,也可能需要先加载指定 Skill,再调用 RAG;拿到检索结果后,模型还要判断证据是否足以回答。这些分支很难提前穷举。

ReAct 的价值就在这里。它不要求系统预先写死每一步,而是允许模型在一个短循环中交替执行:

理解当前任务
  → 选择一个被授权的工具
  → 获得公开 Observation
  → 判断是否继续调用工具
  → 生成最终回答

这里的“Reasoning”不等于向用户公开隐藏思维过程。生产系统真正需要保存的是可审计的外部事实:路由结果、工具名称、经过脱敏的参数、公开 Observation、引用和最终结果。模型内部的隐式推理既不应该作为权限依据,也不需要进入 SSE 或审计记录。

ReAct 解决的是运行时路径不确定的问题,但它并不自动解决安全、持久化和权限问题。因此,我采用的是 bounded ReAct:模型可以动态选择路径,但只能在代码预先定义的工具集合、调用次数和时间上限内行动。

三、为什么选择 LangChain,而不是自己手写 Agent 循环

一个最小 ReAct 循环并不难手写:请求模型、解析 tool call、执行函数、把结果作为 Tool Message 追加回上下文,再次请求模型即可。困难在于,当它进入真实项目后,还要处理模型消息格式、结构化工具 Schema、异步执行、事件流、调用限制、中间件和供应商兼容。

LangChain 的价值不是替项目做所有架构决策,而是提供一个稳定的模型—消息—工具编排层。当前实现使用 create_agent 创建 Agent,并通过 astream_events 获取模型和工具的运行事件。

下面是与项目实现一致的简化代码:

tools = [
    registry[name]
    for name in request.tool_names
    if name in registry
]

agent = create_agent(
    model=llm_provider.create_chat_model(),
    tools=tools,
    system_prompt=request.system_prompt,
    middleware=build_agent_middleware(request.execution_budget),
)

async for event in agent.astream_events(
    {"messages": messages},
    version="v2",
):
    yield to_public_chat_event(event)

这个选择减少了编排层的重复实现,但边界必须说清楚:

能力LangChain 负责项目负责
模型接入统一 Chat Model 调用接口模型配置、供应商参数与失败策略
工具调用Tool Schema、Tool Message、Agent 循环工具注册、权限、参数注入和审计
消息编排模型可消费的消息结构会话归属、记忆筛选与上下文预算
运行控制Agent middleware 和事件接口总调用预算、全局 deadline、重试语义
流式输出提供运行事件映射成稳定的 SSE 公共契约
持久化不作为本项目的业务真相来源PostgreSQL Run、Event、Message、Audit
高风险动作不替业务判断人工确认、幂等与 fail-closed

所以,选择 LangChain 不是为了少写所有代码,而是为了把精力放在真正属于业务的控制面上。

四、为什么第一步不是调用模型,而是 Intent Router

如果模型一开始就拿到全部工具,它实际上同时承担了两项职责:理解用户想做什么,以及决定自己拥有什么权限。这两项职责不能混在一起。

当前 Intent Router 先执行输入安全检查,再处理显式资源标识。例如,请求中包含合法的 incident_*diagnostic_* 标识时,系统优先使用确定性规则;无法通过规则判断的普通自然语言,再交给一个受限的 LLM 分类器。

分类器只能返回固定 JSON 字段:

{
  "intent": "knowledge_question",
  "confidence": 0.86,
  "incidentId": null,
  "diagnosticTaskId": null,
  "needsClarification": false
}

系统会重新校验意图枚举、置信度范围和资源 ID 格式。置信度低于阈值、需要目标却没有提取到目标,都会进入澄清状态。分类调用失败时则安全回退到普通对话,而不是凭空授予诊断或恢复能力。

路由结果只保存公开且可审计的字段:intentconfidencesource、资源 ID、是否需要澄清以及阻断原因,不保存也不依赖模型的隐藏推理。

这一层的意义是:模型可以参与分类,但权限只由经过验证的分类结果派生。

五、为什么 Router 后面还需要 Execution Policy

知道用户意图,并不等于知道应该怎样执行。Execution Policy 把一个已经验证的 Route 编译成不可变策略,核心字段包括:

  • 执行模式;
  • 当前任务允许和要求的工具;
  • 最大模型调用数与工具调用数;
  • 整体 deadline;
  • 执行成功必须满足的 postcondition。

它最终把请求分成三条路径。

1. direct_read:能确定性完成,就不启动 ReAct

“查看当前事故”或“读取某个诊断报告”已经具有明确目标和固定数据源。让模型先选择 get_diagnostic_report,不仅增加一次模型调用,还可能选错工具或遗漏关键安全字段。

因此,这类请求直接调用 owner-scoped 的 Bridge Service,得到结构化结果,再验证 postcondition。例如诊断报告必须包含恢复模式、是否允许执行、是否需要人工审批、Validator 状态和证据 ID 等字段。可选的 LLM 解释失败时,结构化结果仍然成立,系统只把状态标记为“解释降级”。

这体现了一个很实用的原则:

事实读取是主结果,语言润色只是可降级的视图。

2. confirmation_required:先生成待确认动作,不直接执行

启动诊断和恢复请求会改变外部状态。当前链路不会让 ReAct 在一次对话中直接完成它们,而是先创建 Pending Action,把操作目标、原因和关联 Run 固化下来,再向前端发送 confirmation.required

用户确认后,系统才能进入后续业务流程。尤其是恢复请求,Chat Agent 创建的是审批或恢复意图,而不是直接执行重启、终止连接等动作。

3. bounded_react:只给开放式任务动态决策权

普通聊天和知识问答进入 ReAct,但预算并不相同。知识问答允许一次 Query Rewrite,并要求知识检索工具可用;普通聊天只需要更小的模型和工具预算。澄清状态下,创建诊断和恢复审批等工具会被移出允许集合。

简化后的策略可以表达为:

if intent == "diagnostic_status":
    return direct_read(postcondition="diagnostic_result")

if intent == "recovery_request":
    return confirmation_required(capability="recovery_approval")

if intent == "knowledge_question":
    return bounded_react(
        required_tools={"knowledge_retrieval"},
        max_model_calls=3,
        max_tool_calls=2,
        max_query_rewrite_calls=1,
        deadline_seconds=120,
    )

这一步让“是否使用 ReAct”从模型偏好变成了系统策略。

六、工具都在本地,为什么模型仍然只能看到一部分

工具注册表回答的是“系统里有什么”,而当前请求的工具目录回答的是“模型现在能看见什么”。两者不是同一个概念。

当前工具来源包括本地工具、知识检索、渐进式 Skill 加载、AIOps Bridge,以及用户启用的 MCP 工具。Runner 不会把 Registry 全量传给模型,而是根据已经验证的意图生成允许列表,再与运行时实际存在的工具取交集。

例如:

意图模型可见的典型工具
普通聊天当前时间、加载 Skill
知识问答知识检索、加载 Skill
事故查询事故列表、事故详情
诊断状态状态、报告、证据查询
恢复请求状态、报告、创建审批请求

工具的 owner 身份不会作为模型可填写的参数暴露。AIOps Bridge 在构造工具时,从认证上下文闭包注入 owner_user_id,模型只能提供诸如 task_idlimit 等业务参数。这样可以避免模型通过改写 owner 字段访问其他用户的数据。

项目中还实现了独立的 ToolCatalog.compile():它可以检查必需工具是否缺失、生成稳定目录版本,并对缺失能力显式失败。

这是我认为架构拆解中不能回避的一点:有了正确抽象,不代表生产路径已经完整使用了它。

七、怎样防止 ReAct 无限循环或重复调用

受控 ReAct 至少需要三层预算。

第一层是模型调用上限。第二层是工具调用上限。第三层是覆盖整个异步事件流的单调时钟 deadline,而不是每收到一个事件就重新计时。

当前实现使用 LangChain 的 Model/Tool Call Limit Middleware,再增加一个请求级重复调用保护:工具名和规范化参数完全相同时,第二次调用会被阻断。

fingerprint = canonical_json({
    "name": tool_call["name"],
    "args": tool_call.get("args", {}),
})

if fingerprint in seen:
    raise RepeatedToolCallError()

seen.add(fingerprint)

这不是说同一个工具永远不能调用两次。不同时间窗口、不同资源 ID 或不同查询参数仍会得到不同指纹。它阻止的是模型在没有获得新信息时,对完全相同的动作原地打转。

对于可能跨网络重试的 Bridge 工具,仅靠进程内 seen 还不够。系统还会基于 Chat Run、逻辑步骤、工具名和规范化参数生成稳定 tool_call_key,通过持久化 claim、lease 和结果复用实现幂等。只读调用失败后可以安全重试;有副作用的调用如果结果未知,则进入 uncertain,不能盲目补执行。

八、当前上下文是怎样装配出来的

当对话变长后,一个常见现象是:模型明明拥有更长的上下文窗口,回答反而开始忽略最近要求、混淆旧结论,甚至把历史摘要中的一句话当成新指令。

原因是“上下文窗口很大”和“上下文组织正确”是两件事。如果把系统 Prompt、全部工具 Schema、所有 Skill 正文、完整历史、记忆和工具结果直接拼接起来,模型面对的不是更完整的信息,而是一份没有优先级和信任边界的材料堆。

当前实现因此没有直接执行 history + new_message,而是在调用 LangChain Agent 前,先通过 ChatMemoryServiceContextEnvelopeService 构造一个有边界的 Context Envelope:

持久化会话与历史消息
        |
        v
找出尚未压缩的消息 + 本轮候选消息
        |
        v
估算上下文占用,必要时压缩旧消息
        |
        v
Context Envelope
  |- System Prompt
  |- 当前允许的工具描述
  |- 带来源的结构化记忆
  |- 最近 6 个完整对话轮
  |- 有预算的公开 Observation
  `- 为模型输出预留的 Token
        |
        v
LangChain create_agent

1. 上下文由哪些部分组成

ContextEnvelope 不只返回消息,还会记录各部分的预算:

上下文组成当前来源为什么需要单独计算
System Prompt默认约束、用户选择的 Prompt、Skill 目录权限和行为约束不能被普通消息挤掉
Tool Schema当前意图允许的工具描述工具越多,Schema 本身占用越高
Structured MemoryPostgreSQL 中的会话结构化记忆保存跨轮目标、事实、决策和资源引用
Recent Turns最近 6 个完整 user/assistant 轮次保留当前对话连续性,避免从半轮中间截断
Observations已裁剪的公开工具结果摘要为本轮事实证据提供空间,但不能无限增长
Output Reserve至少按窗口的约 10% 预留防止输入刚好塞满窗口,模型没有空间回答

这里最重要的不是某个固定比例,而是每一类内容都有独立身份。系统能够知道空间究竟消耗在 Prompt、工具、记忆还是历史消息上,而不是只得到一个总 Token 数。

2. 一条新消息进入后发生了什么

用户消息到达后,系统先读取当前用户所属的 Session 和消息历史,并根据 memory_through_message_id 找出尚未进入结构化记忆的部分。随后创建一条只用于估算的 candidate message;在预算通过以前,这条候选消息不会因为上下文超限而被提前写入正常执行链。

准备过程可以简化为:

uncompacted = uncompacted_history(history, session)
candidate_messages = [*uncompacted, candidate_user_message]

if usage(candidate_messages) >= synchronous_threshold:
    compact_old_messages_with_cas()

envelope = ContextEnvelopeService().prepare(
    system_prompt=system_prompt,
    messages=candidate_messages,
    structured_memory=session.structured_memory,
    tool_schemas=allowed_tools,
    window_tokens=context_window_tokens,
)

ContextEnvelopeService 会保留最近 6 个完整对话轮,以及尚未形成完整轮次的尾部消息。它不会简单截取最后 N 条消息,因为最后 N 条可能从一个 assistant 回答中间开始,使问题和回答失去对应关系。

完成装配后,Envelope 把安全处理后的记忆追加到 System Prompt,把裁剪后的消息交给 LangChainChatAgentRunner。Runner 最终只接收已经准备好的 request.system_promptrequest.messages,不再自行读取整段数据库历史。

3. 60%、85% 和 95% 分别代表什么

当前自适应记忆采用三档处理,但三者发生在不同阶段:

阈值行为目的
60%一轮对话完成并刷新用量后,调度后台结构化压缩提前整理旧历史,不阻塞当前回答
85%下一轮请求装配前,同步压缩六个最近轮次之前的旧消息避免高占用请求直接撞上硬上限
95%Context Envelope 拒绝继续执行并返回 CHAT_CONTEXT_LIMIT_REACHED为输出和运行波动保留最后安全空间

后台压缩任务使用稳定 Job ID,最多重试三次,并通过 PostgreSQL Compare-And-Set 更新 memory_summary_version。如果两个 Worker 同时基于旧版本生成记忆,只有版本仍匹配的结果能够写入;较晚到达的旧结果不会覆盖新记忆。

需要注意,压缩并不删除原始聊天记录。memory_through_message_id 只表示“哪些消息已经被记忆覆盖”,完整消息仍然保存在数据库中,用于会话展示和审计。

4. 结构化记忆保存什么,又拒绝保存什么

早期自由文本摘要有一个明显问题:模型可能把摘要中的命令式文本当成系统指令,也无法区分“用户确认的事实”和“助手提出的猜测”。当前结构化记忆只允许六类数据:

  • 用户目标 user_goals
  • 已确认事实 confirmed_facts
  • 用户偏好 preferences
  • 已形成的决策 decisions
  • 未完成事项 open_tasks
  • 资源引用 resource_refs

每一项都必须携带来源消息 ID、可选 Citation ID 和信任级别。信任级别区分用户陈述、用户确认、工具证实和助手提议;其中 confirmed_facts 只接受用户确认或工具证实的内容,助手自己提出的判断不能直接升级成事实。

压缩 Prompt 也明确禁止保存隐藏推理、Prompt、凭据、原始工具输出和恢复安全状态。Pydantic 模型会再次检查字段白名单、单项长度、总大小和疑似指令文本。进入 Context Envelope 后,系统还会过滤 executionPermittedrecoveryModevalidatorStatus 等安全状态标记。

这意味着记忆可以提醒模型“用户正在排查哪个任务”,但不能告诉系统“之前已经允许自动恢复”。真正的恢复权限必须重新从当前结构化报告和 Policy Gate 获取。

旧版自由文本摘要仍可在迁移期被读取,但会被包在明确的不可信数据说明中:它只能作为引用,不能作为系统指令或已确认事实。结构化记忆生成失败时,压缩状态会变成 degraded,而不是把无法校验的模型输出写入数据库。

5. Skill 和 RAG 怎样进入上下文

Skill 使用渐进式披露。System Prompt 初始只包含当前已选择 Skill 的名称和描述;模型判断某个 Skill 与任务匹配后,必须调用 load_skill 获取完整正文。这样避免每轮对话重复装入全部 Skill 指令。

RAG 则作为 knowledge_retrieval 工具进入 ReAct。Query Rewrite、Milvus 向量召回、BM25L、RRF、Rerank 和 Citation 都发生在工具内部,返回的是本轮 Observation,而不是永久写入系统 Prompt。只有经过后续记忆压缩且满足来源与信任规则的信息,才有机会成为跨轮记忆。

因此,三者的生命周期不同:

Skill 描述:启动时进入能力目录
Skill 正文:按需加载到当前轮
RAG 结果:作为当前轮工具 Observation
Structured Memory:经过验证后跨轮复用

九、一次请求在系统里究竟怎样流动

以“查询某个知识库中的数据库死锁处理方式”为例,完整时序如下:

Client          API / Run          Router & Policy       LangChain Agent       RAG Tool
  |                 |                     |                     |                  |
  | POST message    |                     |                     |                  |
  |---------------->| persist user/run    |                     |                  |
  |                 |-------------------->| safety + intent     |                  |
  |                 |<--------------------| bounded_react       |                  |
  |                 | compile context/tool allowlist            |                  |
  |                 |------------------------------------------>| model decides    |
  |                 |                     |                     |----------------->|
  |                 |                     |                     | citation results |
  |                 |                     |                     |<-----------------|
  |< - - - - - - - | SSE tool/citation/content events          |                  |
  |                 | persist public events + audit             |                  |
  |<----------------| complete                                  |                  |

如果请求换成“查看 diagnostic_xxx”,Policy 会选择 direct_read,中间的 LangChain Agent 和 RAG Tool 都不会出现。如果换成恢复请求,则在生成 Pending Action 后停止,等待用户确认。

这个差异正是混合架构的价值:同一个聊天入口可以提供统一体验,但内部执行链不必为了“看起来像 Agent”而强行一致。

十、为什么 SSE 不是持久化方案

SSE 适合把内容增量、工具状态、引用、确认请求和完成事件实时展示给前端,但连接可能中断。若只依赖当前 HTTP 进程,一次较长的模型调用或工具调用就可能随着断线丢失。

因此,当前 Chat Run 运行在已有的持久后台任务框架上:

  1. PostgreSQL 中先保存 Run 和用户消息;
  2. Worker claim 当前 attempt,并写入 run.statusrun.restarted
  3. 执行 Chat Streaming Service,把公共事件追加到 Run Event;
  4. 使用稳定的 assistant message ID 防止重试时重复写回答;
  5. 对错误分类,区分可重试失败与终止失败;
  6. 完成时原子保存结果与 complete event。

因此,两者承担不同职责:

SSE:当前连接中的实时投影
PostgreSQL Run/Event:可恢复、可重放的事实来源
Tool Audit:工具实际执行过程的审计记录

用户断线后重新连接,应该从持久事件继续读取,而不是要求模型重新思考一遍。Worker 重启后,也应从 Run 状态和幂等记录恢复,而不是重复执行有副作用的工具。

十一、Chat ReAct Agent 与 LangGraph AIOps Agent 有什么不同

项目中同时使用 LangChain Chat Agent 与 LangGraph AIOps Agent,并不是重复建设。它们解决的是两类不同的不确定性。

对比项Chat ReAct AgentLangGraph AIOps Agent
主要输入开放式自然语言请求已进入诊断域的事故与证据范围
不确定性下一步是否需要工具、选哪个工具证据是否充分、假设是否成立、是否需要重规划
主控制方式Router 后的短 ReAct 循环显式状态图和条件边
步骤顺序允许模型在预算内动态决定Planner、Executor、Evidence Gate、Decision 等强依赖
状态对话消息与短期 Observation诊断状态、证据、假设、Validator、Checkpoint
高风险动作创建待确认操作Recovery Planner + Validator Router + Policy Gate
适合场景问答、知识检索、统一入口故障调查、差分假设、恢复决策与报告

当前 AIOps 图包含 Planner、Executor、Fact Adapter、Sufficiency Gate、Hypothesis Adjudicator、Replanner、Decision、Deterministic Validator、可选 LLM Validator、Recovery Planner、Policy Gate 和 Report。复杂场景还可以通过 Strategy Router 分发 Specialist,再聚合证据。

这条链路的关键是节点之间存在强顺序和可验证状态。例如,证据不足时必须回到 Executor 或 Replanner;确定性验证失败时不能直接进入恢复;语义风险较高时才路由到 LLM Validator;最后仍由 Policy Gate 决定是否允许执行。

因此,选择 ReAct 还是 LangGraph,不应该变成“哪个框架更强”的争论。即便 LangChain 的高层 Agent 在内部也可能使用图运行时,项目层面的选择仍然是:

  • 路径主要由模型动态决定,使用受控 ReAct;
  • 路径必须被业务状态和安全门严格约束,显式建模为 LangGraph。

Chat Agent 负责理解入口请求并把用户带到正确能力,AIOps Agent 负责完成证据驱动的长流程诊断。

十二、怎样验证这套架构不是“只看起来合理”

只测试最终回复文本,很难发现权限和编排错误。一个回答可能语义正确,却调用了禁止工具;也可能没有完成必需工具调用,只靠模型猜出了答案。

当前 Conversation Eval 因此分别检查:

  • Intent 与执行模式是否正确;
  • 目标资源 ID 是否正确提取;
  • 允许工具精度与必需工具召回;
  • Direct Read 是否真正绕过 ReAct;
  • 高风险操作是否进入确认;
  • 调用预算是否被遵守;
  • 结果是否包含必要 grounding;
  • 幂等、跨租户隔离与 SSE 重放是否正确。

同时设置硬门禁:禁止工具调用、跨租户访问、未确认写操作、预算后调用、安全字段不一致、自动执行恢复,以及向公共事件泄露隐藏推理,只要出现一次就不能判定通过。

这类 Eval 的目标不是证明模型“聪明”,而是验证系统控制面在模型输出变化时仍然成立。

总结:生产化的重点不是让 ReAct 做得更多

回到开头的三个请求:知识问题可以进入有预算的 ReAct,状态查询直接读取结构化事实,恢复请求停在人工确认边界。它们共享一个聊天入口,却不共享同一种执行方式。

这套架构最重要的不是使用了多少 Agent 名词,而是完成了职责分离:

  • Router 判断请求属于哪个能力域;
  • Execution Policy 决定是否允许 ReAct、允许哪些工具以及预算多少;
  • LangChain 承担模型—工具—Observation 的通用循环;
  • Tool Bridge 注入身份、处理幂等并隔离副作用;
  • Context Envelope 控制 Prompt、Skill、Memory 和 Observation;
  • PostgreSQL Run/Event 与 Audit 提供恢复和审计;
  • LangGraph 承担强顺序、证据驱动的 AIOps 诊断链路。

如果要继续深入这套系统,接下来值得追问的不是“要不要再加一个 Agent”,而是三个更具体的问题:工具目录是否与实际权限完全一致?失败重试能否证明不会重复产生副作用?一次回答是否能还原为公开证据、执行事件和明确的安全决策?

这些问题,才是 Chat Agent 从可演示走向可运行的分界线。

陈涛 · Agent Application Developer

杭州 · 2026