我最近在做一个项目管理系统的 AI 模块——不是独立的聊天窗口,而是嵌入到业务页面里的智能助手。用户在项目详情页、任务列表、首页工作台等不同页面,都可以直接跟 AI 交互:拆解任务、生成周报、分析延期风险。

听起来不复杂,但做到后面发现,Agent 架构中最容易被忽视、却最容易出问题的环节,不是模型选型,不是 prompt 调优,而是上下文的流动方式——哪些信息该给谁看,哪些信息不该给谁看。

这篇文章会先介绍一些 Agent 的基础概念,从最初的 ReAct 模式,到 Plan-Execute,再到 Router 分发,最终演化出"三层上下文裁剪"模式的思考。


一、先聊几个基础概念

如果你已经熟悉 Agent、ReAct、LangGraph 这些概念,可以直接跳到第二节。

什么是 Agent

在 AI 语境下,Agent 不是简单的"聊天机器人"。聊天机器人只做一件事:接收文本,返回文本。而 Agent 可以自主决策并执行动作

一个典型的 Agent 工作流程是:

用户输入 → 理解意图 → 决定做什么 → 调用工具执行 → 观察结果 → 决定下一步 → ... → 返回最终回答

中间的"决定做什么"和"调用工具"是关键区别。Agent 可以查数据库、调 API、创建任务、发送通知——它不只是生成文字,而是真的在做事

这也意味着 Agent 比聊天机器人危险得多:聊天机器人出错只是说错话(文本风险),Agent 出错是做错事(操作风险)——它可能删掉你的任务、修改错误的数据。

什么是 Tool(工具)

Tool 是 Agent 能调用的具体能力。你可以理解为 Agent 的"手脚":

  • query_tasks:查询任务列表
  • create_task:创建新任务
  • update_task:修改任务状态
  • delete_task:删除任务

Agent 本身是大脑(LLM),Tool 是它能操作外部世界的接口。Agent 根据用户意图,决定调用哪个 Tool、传什么参数。

什么是 LangGraph

LangGraph 是 LangChain 团队开发的一个框架,用于构建基于图(Graph)的 Agent 系统。它的核心思想是把 Agent 的工作流建模为一个状态机:

  • 节点(Node):每个节点是一个处理步骤,比如"路由判断"、“工具执行”、“生成回答”
  • 边(Edge):节点之间的连接关系,可以是固定的,也可以是条件分支
  • 状态(State):在节点之间流转的共享数据

相比直接用 LLM 做链式调用,LangGraph 的优势在于:支持循环(Agent 可以反复思考)、支持中断和恢复(需要人工审批时可以暂停)、状态可持久化(断线重连不丢失进度)。

什么是上下文(Context)

Agent 做决策时需要知道"当前状况"。在我的场景里,上下文就是用户当前的页面状态:

  • 用户在哪个页面(首页/项目详情/任务列表)
  • 选中了什么对象(某个任务/某个项目)
  • 当前的筛选条件(状态:进行中,优先级:P1)
  • 请求从哪个入口发起的(侧边栏/快捷按钮/AI 工作台)

这些信息决定了 Agent 应该怎么理解用户的意图、怎么执行操作。问题在于:不同的处理环节需要的信息量是完全不同的


二、架构演进:从 ReAct 到三层裁剪

第一站:ReAct 模式——“让 LLM 自己想”

我最初的方案是业内最经典的 ReAct(Reasoning + Acting)模式。

ReAct 是什么

ReAct 的核心循环是:

思考(Thought) → 行动(Action) → 观察(Observation) → 思考 → 行动 → ... → 最终回答

每一轮,LLM 先"想"一下当前应该做什么,然后选择一个 Tool 执行,拿到结果后再"想"下一步,直到它认为已经有足够的信息来回答用户。

整个过程中,LLM 拥有完全的自主权——选哪个工具、传什么参数、循环几次,全由模型自己决定。

我为什么先选了它

原因很简单:ReAct 是最灵活的模式,几乎所有 Agent 教程的第一课都在教它。而且 LangGraph 直接提供了 createReactAgent 这个开箱即用的工具。我只需要定义好 Tool,把用户消息丢进去,Agent 就能"自动"工作了。

遇到的问题

第一个:不可预测性。

用户说"帮我看看这个任务的进度",我期望 Agent 调用 query_task 查一下然后回答。但 ReAct 模式下,模型有时候会:先查任务详情,再查项目详情,再查团队成员负载,最后给出一个"综合分析"——用户只是想看个进度,不需要这么复杂的分析链路。更麻烦的是,同样的输入,不同时间可能走出不同的路径。

第二个:成本不可控。

ReAct 的循环次数由模型自己决定。一个简单的任务查询,模型可能"谨慎地"循环 4-5 轮(思考→调用→观察→再思考…),每一轮都在消耗 token。而其中很多轮次的"思考"是不必要的。

第三个:安全风险。

ReAct 模式下模型可以自由选择任何 Tool。我没有一个可靠的机制来确保"删除任务"这种高风险操作在执行前必须经过用户确认。只靠 prompt 里写"删除前请确认"是不够的——Anthropic 自己的工程建议里就明确说过:不能只靠 prompt 来防护高风险操作

思考

ReAct 的问题本质是:它把所有的决策权都交给了 LLM,但 LLM 并不是所有环节的最优决策者。 对于"查询任务进度"这种有明确固定流程的操作,我不需要 LLM 来"思考"应该怎么做——直接走固定流程就好。

这让我开始思考一个原则:确定性 workflow 优先,只有在真正需要开放式决策时才引入 LLM 推理。这条原则贯穿了后续所有的架构选择。


第二站:Plan-Execute 模式——“先规划再执行”

Plan-Execute 是什么

这个模式把 ReAct 的"边想边做"拆成了两个明确阶段:

Planner(规划)→ 生成步骤列表 → Executor(执行)→ 逐步执行 → 结果不对?→ Replanner(重新规划)

Planner 用一个强模型(比如 GPT-4 级别)来分析任务、生成完整的执行计划;Executor 用一个轻量模型或固定代码逐步执行计划;如果中间结果偏离预期,Replanner 会调整计划。

比 ReAct 好在哪

Plan-Execute 解决了 ReAct 的一部分不可预测性问题。因为计划是预先生成的,我至少可以在执行前看到 Agent 打算做什么,甚至可以让用户确认计划后再执行。这比 ReAct 的"黑盒循环"可控多了。

新的问题

过度规划。

用户说"帮我创建一个任务:修复登录页面的样式"。这是一个再明确不过的指令——直接调用 create_task 就行了。但 Plan-Execute 模式下,Planner 仍然会被触发,花几秒钟"规划"一个只有一步的计划。这个规划过程消耗了一次强模型调用的成本和延迟,却没有产生任何价值。

所有请求走同一条路。

不管是"创建一个任务"(简单 CRUD)还是"帮我分析这个项目所有延期任务的原因"(需要深度推理),都要经过 Planner → Executor → Replanner 这条路径。简单操作被迫走了复杂链路,复杂操作的 Planner 又不够专业(它是通用的,不是针对特定业务场景优化的)。

Replanner 的引入增加了系统复杂度。

Replanner 需要判断"当前结果是否偏离预期",这本身就是一个模糊的判断。什么叫"偏离"?偏离多少要重新规划?重新规划几次就该放弃?这些都需要额外的设计和测试,而很多场景根本用不到 Replanner。

思考

Plan-Execute 的问题在于:它假设所有请求都需要"规划"这个步骤,但现实中大量请求是可以直接执行的。 我需要一个机制来区分"需要规划的复杂请求"和"可以直接执行的简单请求"。


第三站:Router 分发模式——“先分流再处理”

Router 是什么

Router(路由器)的思路是:在 Planner 之前加一个轻量的分发层,先判断用户的请求属于哪个类别,然后根据类别走不同的处理路径。

用户输入 → Router(意图分类)
               ├─ 简单 CRUD → 直接执行(不经过 Planner)
               ├─ 数据查询 → SQL 查询 + 结果解释
               ├─ 任务拆解 → Planner → Executor → Replanner
               └─ 周报生成 → 固定 Pipeline(取数→聚合→润色)

这解决了 Plan-Execute 的"所有请求走同一条路"问题——简单操作走快速路径,复杂操作才启用完整的规划链路。

我的两层路由设计

进一步优化后,我把路由拆成了两层:

第一层:Fast-path Router(零 LLM 调用)。 前端的快捷按钮、卡片操作等结构化入口,直接映射到对应的能力模块,完全不需要 LLM 来判断意图。比如用户点了"生成周报"按钮——意图已经明确了,不需要模型来"理解"。

第二层:LLM Fallback Router(仅处理自由文本)。 只有用户在输入框里用自然语言提问时,才需要 LLM 来判断意图。而且这个 LLM 调用使用最轻量的模型,只做分类,不做推理。

这个两层设计的效果是:在我的场景中,预估超过 60% 的请求可以走 Fast-path,零 LLM 调用就完成路由。剩下 40% 的自由文本请求走轻量 LLM 路由,成本也远低于直接启动一个 ReAct 循环。

到这里为止,还有什么问题?

Router 分发解决了"不同请求走不同路径"的问题,但引入了一个新的问题:不同路径上的节点需要的上下文信息是不同的,但它们拿到的上下文是一样的。

具体来说,前端会传一个完整的页面上下文给后端,包含当前页面、选中的对象、筛选条件、可见的列表数据、分页信息等。这个上下文对象会被注入到 LangGraph 的 State 里,然后所有节点——Router、Executor、Tool、LLM prompt——都能读到全部内容。

刚开始我觉得这没什么问题:节点用得上的字段就用,用不上的就忽略呗。

但这个"忽略"的假设,很快就出事了。


第四站:三层上下文裁剪——“从源头控制信息流动”

触发我重新思考的三件事

事件一:Token 账单异常。

我在测试环境跑了一批模拟请求后发现,Router 节点消耗的 token 数远超预期。排查后发现,前端传过来的 visibleEntityIds(当前列表可见的条目 ID 列表)有时候多达 40-50 个 ID。这些 ID 被完整地塞进了 Router 的 prompt 里——但 Router 的工作仅仅是判断"用户想做什么类别的事",它根本不需要知道列表里有哪些具体条目。

每次请求白白多消耗几百 token,单次不多,但乘以日均请求量,一个月下来是一笔实际的成本。

事件二:Tool 里出现了不该有的 if-else。

我在 code review 时发现一段代码:某个 Tool 在执行查询时,根据 origin(请求来源)字段返回不同详细程度的结果——如果请求来自侧边栏就返回简要信息,来自 AI 工作台就返回详细信息。

这段代码"能跑",但它意味着 Tool 和 UI 入口产生了耦合。如果以后新增一个入口(比如从甘特图发起的分析),这个 Tool 就要再加一个 if 分支。而 origin 本来是路由层的信息,Tool 不应该关心请求从哪来,它只应该关心"操作什么数据"。

这段代码之所以能写出来,是因为 Tool 能读到 origin 字段——信息可达就会被使用,这是工程中的必然规律。

事件三:安全隐患。

我在考虑 prompt injection 防护时意识到,如果 LLM 的 system prompt 里直接拼入了完整的页面上下文 JSON,攻击者可以利用这些结构化信息构造更精准的注入指令。而 LLM 其实只需要一句自然语言描述就能理解当前场景——它不需要看到原始的数据结构。

设计思路

这三件事的共同根源是:所有节点都能看到全部上下文,但没有任何机制约束"谁该看什么"。

我重新审视了整个 Agent 系统中的角色分工,发现有且只有三类上下文消费者:

1. 路由层(Router)。 它的任务是判断用户意图——“用户想做什么类别的事”。它需要知道用户在哪个页面、从哪个入口发起、选中了什么对象。它不需要知道列表有多长、筛选条件的细节、分页在第几页。

2. 执行层(Tool / Executor)。 它的任务是具体执行操作——查数据库、创建任务、修改状态。它需要知道操作的目标对象、筛选条件、分页信息。它不需要知道请求从哪个入口来——Tool 的行为不应该因为来源不同而改变。

3. 模型层(LLM system prompt)。 它的任务是理解场景并生成自然语言回答。它需要一个对当前页面的自然语言描述。它不需要原始的 JSON 结构、ID 列表、分页参数。

三类消费者,三种信息需求。那答案就很自然了:在信息进入 Agent 系统之前,按消费者裁剪成三份,每份只包含该消费者需要的字段。

这就是 Context Assembler——上下文装配器。它是后端的一个函数,在请求进入 Agent 图之前执行,把前端传来的完整页面上下文拆成三个视图:

前端传入:完整的页面上下文
            │
     Context Assembler(后端,执行一次)
            │
    ┌───────┼───────────────┐
    ▼       ▼               ▼
 路由上下文  执行上下文      模型上下文摘要
 (RouterCtx) (ExecCtx)     (ModelSummary)
    │       │               │
    ▼       ▼               ▼
 Router    Tool/Executor    LLM System Prompt

三份视图存入 Agent 的状态中,各节点只读取自己对应的那份。


三、三层裁剪的设计细节

字段如何分配

字段分配的判断标准不是"这个字段对这一层有没有用",而是**“给了这个字段之后,这一层会不会做出不该做的事”**。

下面逐个分析关键字段的归属:

origin(请求来源)

这是整个裁剪方案中最重要的隔离字段。

origin 表示请求从哪个 UI 入口发起:侧边栏、快捷按钮、任务行操作、AI 工作台等。

  • 路由层:需要。 Fast-path Router 的核心依据就是 origin——“用户从快捷按钮点了生成周报”,origin 直接告诉 Router 该映射到哪个能力模块,无需 LLM 介入。
  • 执行层:禁止给。 一旦 Tool 能读到 origin,开发者就会写出"首页来的返回简要版、工作台来的返回详细版"这种逻辑。Tool 的行为应该只取决于"操作什么数据",不取决于"从哪来的"。如果产品确实需要不同入口返回不同详细度,这应该是路由层选择不同能力模块来实现,而不是同一个 Tool 内部 if-else。
  • 模型层:不需要。 LLM 不需要知道"这个请求来自侧边栏",这对生成回答没有帮助。
visibleEntityIds(当前列表可见条目)

这是最容易造成 token 浪费的字段。

一个任务列表页可能同时展示 20-50 条任务的 ID。如果全量传入 Agent,每个 ID 大约 10-15 个字符,50 个 ID 就是 500-750 个字符,折合约 200 token。

  • 路由层:禁止给。 Router 判断意图不需要知道列表内容。如果 Router 能看到这些 ID,可能有人会在路由逻辑里写"列表为空时推荐创建任务"——这不是路由层的职责。
  • 执行层:有条件给。 只有当列表很短(20 条以内)且用户明确针对可见项操作(比如"把这些全部标记完成")时才传入。大多数场景下,Tool 应该根据筛选条件去数据库查询,而不是依赖前端传来的 ID 快照。
  • 模型层:禁止给。 一串 ID 对 LLM 生成自然语言回答没有任何帮助,纯粹是 token 噪音。
filters(筛选条件)

这个字段需要差异化处理。

完整的筛选条件可能包含:状态、负责人、优先级、日期范围、自定义字段等,结构比较复杂。

  • 路由层:只给关键部分。 Router 需要知道"用户在看进行中的任务"来辅助意图判断,但不需要知道"日期范围是 3月1日到3月22日"。所以只抽取影响意图判断的关键字段(status、assignee、priority),形成一个精简的 keyFilters
  • 执行层:给完整的。 Tool 查数据库需要完整的筛选条件。
  • 模型层:给自然语言摘要。 不传 {"status":"in_progress","priority":"P1"},而是传"筛选:进行中、P1 优先级"。
分配总结
字段 路由层 执行层 模型层 判断理由
page(当前页面) 不给 给(文字) 路由需要页面信息;Tool 不关心在哪个页面;模型需要知道场景
origin(请求来源) 不给 不给 只有路由需要来源信息做 fast-path 映射
selectedEntity(选中对象) 给(文字) 三层都需要:路由判断目标、Tool 操作目标、模型描述目标
routeParams(路由参数) 给(文字) 同上,是定位信息
activeTab(当前 Tab) 不给 给(文字) 影响路由意图判断,不影响 Tool 执行
viewMode(视图模式) 不给 不给 给(文字) 只对描述场景有用(“甘特视图”)
filters(筛选条件) 给精简版 给完整版 给摘要 三层需求粒度不同
visibleEntityIds 不给 有条件给 不给 最危险的膨胀源,严格控制
searchKeyword 不给 不给 纯执行参数
pagination(分页) 不给 不给 纯执行参数

模型层为什么必须是自然语言摘要

关于模型层拿到的应该是原始 JSON 还是自然语言摘要,我做了一个明确的选择:只给自然语言摘要。

Token 效率差距明显。 同样的信息,JSON 格式大约 80 token,自然语言摘要大约 35 token。每次请求省一半,积少成多。

降低 Prompt Injection 风险。 结构化的 JSON 给了攻击者明确的格式线索。如果某个业务数据字段(比如任务标题)被注入了恶意内容,在 JSON 结构中它更容易被模型误解为系统指令。自然语言摘要打断了这种结构化格式,注入内容变成了一段不通顺的描述文字,模型更容易识别其异常。

LLM 的真实需求。 system prompt 里注入上下文的目的是让模型"了解当前场景",不是让模型"拿数据做计算"。真正需要用 ID 查数据库的是 Tool,不是 LLM。模型只需要知道"用户在项目详情页看着甘特图"就够了,projectId: "p123" 这种原始数据对它没有意义。

摘要的生成方式是纯模板拼接,不需要调 LLM。根据页面类型和存在的字段,用固定模板拼出一句话。比如:

“用户当前在项目详情页,项目 p123,Tab 进行中,甘特视图,选中了任务 t456,筛选:P1 优先级。”

控制在 200 token 以内,简洁明了。


四、状态不可变性:如何保证裁剪后不被污染

做了裁剪只是第一步。三份上下文存入 Agent 状态后,还有一个问题:怎么保证后续节点不会篡改它们?

LangGraph 的状态更新机制是基于 reducer 的——每个状态字段都有一个 reducer 函数,决定"当节点返回新值时如何合并"。

我对三个上下文字段使用了"首次写入后冻结"的 reducer 策略:

  • 第一次写入时(入口节点执行 Context Assembler 的结果),正常存入
  • 之后任何节点如果尝试返回这些字段的新值,reducer 直接丢弃,保留原值

这意味着即使某个节点的代码里不小心 return 了一个修改过的上下文对象,框架层面就会拒绝这次更新。架构约束下沉到了框架机制,而不是浮在代码规范上。 规范可以被忽略,框架机制不会。

再叠加 CI 层面的 grep 检查——比如路由模块的代码里不该出现执行上下文相关的关键词——三道防线(框架拒绝 → CI 报错 → Code Review)就构成了完整的保护。


五、对比总结:四种模式的演进逻辑

回顾整个演进路径,每一次架构切换都不是"为了用新技术",而是上一个方案的某个缺陷在实际开发中暴露后的应对:

模式 核心思路 解决了什么问题 暴露了什么新问题
ReAct LLM 自主循环:思考→行动→观察 最灵活,能应对开放式问题 不可预测、成本不可控、安全风险高
Plan-Execute 先规划完整计划,再逐步执行 可预测性提升,可以预审计划 简单操作也走重型链路,过度规划
Router 分发 先分类意图,不同类型走不同路径 简单走快路、复杂走慢路 所有节点共享全量上下文,信息越权
Router + 三层裁剪 分类意图 + 按消费者裁剪上下文 信息隔离、成本控制、安全增强 当前方案(持续验证中)

四次演进的底层逻辑是同一条线索:逐步收回 LLM 的权限,把确定性还给确定性的模块,把信息控制权从"消费者自律"转移到"供给侧管控"。

ReAct 是把所有决策权交给 LLM;Plan-Execute 是把执行权收回来但规划权还在 LLM;Router 是把路由决策权也尽量收回来(Fast-path 零 LLM);三层裁剪是把信息访问权也收回来(每个节点只看到该看的)。

每一步都在缩小 LLM 的"自由活动空间"——不是因为 LLM 不够强,而是因为在生产系统中,可控性比灵活性更重要


六、防止架构退化

一个架构方案的价值不在于它设计出来的那天有多漂亮,而在于三个月后、十个人协作时它还能不能保持住。

我在设计三层裁剪方案时,同步设计了一份"防滑坡检查表"。它列举了最可能发生的四种退化模式:

退化模式 为什么会发生 后果
“为了赶进度,直接把完整上下文丢给规划节点” 赶工期时最常见的"先跑通再优化" 规划节点是 LLM 节点,全量上下文 = Token 浪费 + 注入面扩大
“路由层里读取了列表数据来辅助意图判断” 看似合理的"优化"——“列表为空时推荐创建” 路由层开始承担业务逻辑,职责边界被打破
“Tool 根据请求来源返回不同格式的结果” 需求驱动——“侧边栏要简洁版,工作台要详细版” UI 入口和执行逻辑耦合,新增入口就要改 Tool
“LLM prompt 里直接拼入了原始 JSON 筛选条件” 图省事——“反正模型能理解 JSON” Token 浪费,且暴露了不必要的数据结构

这份表的价值在于:它把"未来可能出的问题"变成了现在就可以检查的清单。 Code Review 时对照这张表过一遍,就能拦住大部分退化。

我认为,能预见系统会怎么腐化,比设计系统本身更能体现架构能力。任何架构在白板上画出来都是干净的,难的是在真实协作中保持住这个干净。


七、思考

整个演进过程给我最大的感受是:Agent 系统的核心挑战不在于"让 LLM 更强",而在于"给 LLM 恰当的边界"。

初期我花了大量时间在 prompt 调优上——怎么让模型更准确地选择 Tool、更好地理解用户意图。但后来发现,很多问题的根源不是模型不够聪明,而是我给了它太多不该看的信息、太多不该有的权限。

三层上下文裁剪模式的本质就是一个在 LLM 系统中落地的"最小权限原则":

每个环节只应该看到刚好够它完成工作的信息,多一个字段都是负债。

Logo

助力广东及东莞地区开发者,代码托管、在线学习与竞赛、技术交流与分享、资源共享、职业发展,成为松山湖开发者首选的工作与学习平台

更多推荐