引言:当 Agent 跑通之后,然后呢
经过前四天的学习,你已经能够用 LangGraph 构建功能完备的 Agent 系统,从单节点的 ReAct 循环到复杂的多智能体协作网络,代码在本地终端里跑得顺风顺水。然而当你把同样的 Agent 部署到生产环境中面对真实流量时,一系列新的问题会扑面而来。一个用户同时提交十个分析任务时,串行处理会让后面的人等到不耐烦,LLM 调用在深夜的批量跑批中悄然消耗着预算,而你甚至不知道钱花在了哪里,一场持续二十轮的对话积累了上万个 token 的上下文,某一次调用突然超出了模型的上下文窗口限制而导致整个任务崩溃。这些问题不像功能缺陷那样有明确的对错,它们是一种渐进式的退化,系统在低负载下表现完美,但一旦压力上来,延迟、成本、稳定性就会从四面八方侵蚀用户体验。Day 5 的任务就是直面这三个维度的挑战,通过并行处理提升吞吐量,通过 LangSmith 追踪实现对成本的透明化管理,通过上下文压缩策略确保 Agent 在长对话场景下的稳定性。
LCEL 并行处理:用并发换时间
在 LangChain 和 LangGraph 的生态中,并行处理不是一个需要手动管理线程池的底层操作,而是深深嵌入在 Runnable 接口和图的节点调度中的一等公民。理解并行处理的起点是模型层面的 batch() 方法。当你需要让同一个模型对一批彼此独立的输入分别生成回答时,逐个调用 invoke() 意味着每次都要等前一个请求完全返回后才能发出下一个,总耗时是所有请求耗时的累加。而 batch() 方法接受一个输入列表,在内部将这些请求并行发送给模型 API,总耗时逼近单个请求的耗时而非它们的总和。
下面的代码对比了串行与并行的调用方式。被注释掉的部分是串行做法,它需要在循环中逐条等待,而启用 batch() 后六条请求同时发出,responses 列表在最后一个请求完成后统一返回,顺序与输入严格对应。这种方式在批量评估、离线标注、报告生成等场景下带来的加速是数量级层面的,如果你手头有上百篇文章需要生成摘要,串行可能需要几分钟,而 batch() 能把这个时间压缩到几十秒。
python
from langchain.chat_models import init_chat_model
from dotenv import load_dotenv
load_dotenv()
model = init_chat_model("Qwen/Qwen3.6-27B", model_provider="openai")
questions = [
"请介绍一下人工智能的历史和发展趋势。",
"什么是机器学习?它与人工智能有什么关系?",
"深度学习是什么?它在人工智能中的作用是什么?",
"人工智能在医疗领域有哪些应用?",
"人工智能在自动驾驶汽车中的应用有哪些?",
"人工智能在自然语言处理中的应用有哪些?"
]
# 串行方式:每个请求一次等待
# for question in questions:
# answer = model.invoke(question)
# print(answer.content)
# 并行方式:同时发送多个请求
responses = model.batch(questions)
for response in responses:
print(response.content)
当输入量较大时,无限制的并行也意味着风险,同时发出的请求太多可能触发 API 提供商的速率限制,导致部分请求被 429 状态码拒绝或被迫排队。batch() 通过 RunnableConfig 中的 max_concurrency 参数提供了精确的并发控制。下面的代码将 max_concurrency 设为 5,这意味着运行时最多同时维持 5 个进行中的请求,第六个请求必须等待前五个之一完成后才发出,形成一个可调节的"并发窗口"。这一设置是客户端层面的流量整形,它不会改变模型 API 本身的行为,而是限制 LangChain 向 API 发送请求的节奏,因此不会触发服务端的限流错误。在压测时你可以逐步调高这个值,找到吞吐量与稳定性的最佳平衡点。
python
from langchain.chat_models import init_chat_model
from langchain_core .runnables import RunnableConfig
from dotenv import load_dotenv
load_dotenv()
model = init_chat_model("Qwen/Qwen3.6-27B", model_provider="openai")
questions = [
"请介绍一下人工智能的历史和发展趋势。",
"什么是机器学习?它与人工智能有什么关系?",
"深度学习是什么?它在人工智能中的作用是什么?",
"人工智能在医疗领域有哪些应用?",
"人工智能在自动驾驶汽车中的应用有哪些?",
"人工智能在自然语言处理中的应用有哪些?"
]
# 最多同时 3 个请求
responses = model.batch(questions,config=RunnableConfig(max_concurrency=3))
for response in responses:
print(response.content)
有时候你并不想等整批任务全部完成才开始处理结果,比如一个批量翻译任务中,用户希望每翻译完一篇文章就立刻在前端展示,而不是对着空白页面等所有文章翻译完。batch_as_completed() 正是为这种场景设计的。它返回一个迭代器,每个结果一就绪就立刻 yield 出来,让你可以在结果到达的第一时间就开始后续处理。注意 batch_as_completed() 返回的结果顺序很可能与输入顺序不同,因为不同请求的完成时间取决于模型负载和内容长度,所以每个结果会附带它在输入列表中的原始索引以便你按需重排。
python
for idx, response in model.batch_as_completed(list_of_inputs):
print(f"Input {idx} done: {response.content[:50]}...")
batch() 和 batch_as_completed() 解决的都是同一个模型对多个输入的并行问题。但当你的应用需要同时执行多个不同类型的 子任务时,比如对一篇文章同时生成摘要、提取关键词和分析情感倾向,你面对的是另一种并行场景,不同 Chain 或 Runnable 的并行编排。这时该 RunnableParallel 出场了。它是一个 Runnable 构造器,接收一个字典,字典的每个键值对定义了一个子 Runnable。当你调用 RunnableParallel 的 invoke() 时,LangChain 内部会同时触发所有子 Runnable,等待它们全部完成后将结果以同名字段汇聚到一个字典中返回。
下面的示例中,parallel_chain 将 summary_chain、keywords_chain 和 sentiment_chain 三个独立的 chain 做了并行绑定。当 parallel_chain.invoke({"text": article_text}) 执行时,三个 chain 同时开始工作,各自独立调用 LLM,总耗时约等于三个任务中最慢的那个,而不是三者的总和。调用者通过 result["summary"]、result["keywords"] 和 result["sentiment"] 取出各自的结果,就像从普通字典中取值一样自然。
python
from langchain.chat_models import init_chat_model
from langchain_core.runnables import RunnableParallel
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from dotenv import load_dotenv
load_dotenv()
model = init_chat_model("Qwen/Qwen3.6-27B", model_provider="openai")
# 设置三个chain
summary_prompt = ChatPromptTemplate.from_template(
"请用简洁的语言总结以下文本的核心内容,控制在3句话以内:\n\n{text}"
)
summary_chain = summary_prompt | model | StrOutputParser()
keywords_prompt = ChatPromptTemplate.from_template(
"请从以下文本中提取5个关键词,用逗号分隔:\n\n{text}"
)
keywords_chain = keywords_prompt | model | StrOutputParser()
sentiment_prompt = ChatPromptTemplate.from_template(
"请分析以下文本的情感倾向(正面/负面/中性),并简要说明理由:\n\n{text}"
)
sentiment_chain = sentiment_prompt | model | StrOutputParser()
# 你已经有三个独立的chain
parallel_chains = RunnableParallel(
summary=summary_chain,
keywords=keywords_chain,
sentiment=sentiment_chain,
)
# 测试文本
test_text = """
LangChain 是一个强大的框架,用于构建基于大语言模型的应用程序。
它提供了链式调用、记忆管理、工具集成等功能,极大地简化了 LLM 应用的开发流程。
无论是构建聊天机器人、问答系统还是数据分析工具,LangChain 都能提供优雅的解决方案。
"""
result = parallel_chains.invoke({"text": test_text})
print("【摘要】", result["summary"])
print("【关键词】", result["keywords"])
print("【情感分析】", result["sentiment"])
值得强调的是,RunnableParallel 和 batch() 可以组合使用,实现二维并行,输入维度上的批量并发和任务维度上的子任务并发。比如你对 10 篇文章每篇都要做摘要、关键词、情感分析,你可以对 RunnableParallel 的结果调用 batch(),这样 10 × 3 = 30 个独立的 LLM 调用会尽可能多地同时进行,只受 max_concurrency 的约束。
讲完了 LCEL 层面的并行,让我们把视角拉到 LangGraph 的图模型中,这里的并行处理有更深层的语义。当你从节点 A 同时画边到节点 B 和节点 C,LangGraph 会将 B 和 C 放入同一个 superstep 中并发执行。superstep 是 LangGraph 的最小调度单元,同一个 superstep 内的节点并行运行,下一个 superstep 在所有节点都完成后才开始。更关键的是,整个 superstep 是事务性的,如果 B 或 C 中的任何一个抛出异常,两个分支对状态的所有更新都不会被应用,状态回滚到 superstep 开始之前。这意味着你不需要担心 B 的部分结果在 C 失败后污染了状态,LangGraph 替你保证了原子性。
下面的代码构建了一个经典的"扇出-汇聚"图,节点 A 是起点,A 完成后同时扇出到 B 和 C(它们属于同一个 superstep),两个分支各自完成后汇聚到 D,D 将最终结果写入终点。注意 State 中的 aggregate 字段使用了 operator.add 作为 reducer,这是因为 B 和 C 都会向 aggregate 写入值,如果没有 reducer 的合并语义,后完成的节点会覆盖先完成的节点的写入。operator.add 的效果是将两次写入拼接为一个列表,最终 aggregate 的值为 ["A", "B", "C", "D"]。
python
from langchain.chat_models import init_chat_model
from dotenv import load_dotenv
import operator
from typing import Annotated
from typing_extensions import TypedDict
from langgraph.graph import StateGraph, START, END
load_dotenv()
model = init_chat_model("Qwen/Qwen3.6-27B", model_provider="openai")
class State(TypedDict):
# 使用 reducer 合并并行结果
aggregate: Annotated[list, operator.add]
def node_a(state: State) -> str:
return {"aggregate": ["A"]}
def node_b(state: State) -> str:
return {"aggregate": ["B"]}
def node_c(state: State) -> str:
return {"aggregate": ["C"]}
def node_d(state: State) -> str:
return {"aggregate": ["D"]}
graph = StateGraph(State)
graph.add_node("a", node_a)
graph.add_node("b", node_b)
graph.add_node("c", node_c)
graph.add_node("d", node_d)
graph.add_edge(START, "a")
graph.add_edge("a", "b")
graph.add_edge("a", "c")
graph.add_edge("b", "d")
graph.add_edge("c", "d")
graph.add_edge("d", END)
compiled= graph.compile()
# 执行后 aggregate 包含 ["A", "B", "C", "D"],B 和 C 并行执行
result = compiled.invoke({"aggregate": []})
print(result)
需要留意的是,并行 superstep 中各分支的完成顺序是不确定的,这次执行可能是 B 先于 C 完成,下次可能反过来。如果你需要确定性的排序,应该将各分支的输出写入各自独立的状态字段,然后在汇聚节点中显式排序,而不是依赖 reducer 的合并顺序。此外,和 batch() 一样,LangGraph 也支持在图调用时通过 configurable 字段中的 max_concurrency 来限制并行度,当你的图有大量并行分支时,设置这个值可以防止向模型 API 同时发起过多请求。
至此,我们已经覆盖了从单模型批量调用到多 chain 并行编排再到图级节点并发的完整并行处理体系。这些技术共同解决了一个问题,如何在有限的资源下最大化吞吐量。但吞吐量上去了,另一个问题随之而来,你的 Agent 到底花了多少钱?这就引出了我们下一个主题。
成本监控,让每一分钱都算得清楚
在 LLM 应用开发的早期阶段,你通常不会太在意单次调用的成本,几美分的开销与开发效率相比不值一提。但当一个 Agent 上线后每天处理数千次请求、每次请求可能触发十余次模型调用和若干次工具执行时,成本就会从"可以忽略的数字"变成"必须严肃对待的预算项"。更麻烦的是,Agent 的动态特性意味着你无法在代码层面静态预测一次对话会花多少钱,一个简单的查询可能只触发两次模型调用,而一个复杂的数据分析任务可能触发二十次,其中包括使用了更大上下文和更多 token 的检索增强步骤。如果不对成本进行系统性的追踪,你就只能在月底收到账单时才后知后觉。
LangSmith 是 LangChain 生态中的官方可观测性平台,它通过自动 trace 机制将每一次 LLM 调用、每一次工具执行、每一步中间推理都记录为一条结构化的运行记录,并在统一的控制台中提供延迟、token 消耗和成本估算的可视化视图。接入 LangSmith 的基本流程极为简单,安装 langsmith 包,设置 LANGSMITH_API_KEY 和 LANGSMITH_TRACING=true 两个环境变量,LangChain 就会自动将所有调用链路的数据发送到 LangSmith 平台。对于在 LangChain 框架内的模型调用和工具执行,这一过程是完全透明的,你无需修改任何业务代码,trace 数据就已经在平台上了。
但当你的函数不在 LangChain 的标准调用链中时,比如一个自定义的数据预处理函数、一个调用外部 API 的工具、或者直接用 OpenAI SDK 发起的模型请求,你需要通过 @traceable 装饰器显式标注它。@traceable 是 LangSmith SDK 提供的核心装饰器,它的作用是让任意 Python 函数成为 LangSmith trace 树中的一个节点。嵌套调用会自动形成父子关系,如果一个 @traceable 函数内部调用了另一个 @traceable 函数,LangSmith 会将后者记录为前者的子 run,构建出完整的调用层级图。这种上下文传播是完全自动的,不需要你手动传递任何 trace ID。
下面的代码构建了一个三层 trace 树。最外层 research_pipeline 是主流程,它依次调用 search_knowledge_base(标注为 run_type="tool")和 call_model(标注为 run_type="llm")。在 LangSmith 控制台中你会看到一条名为 "Research Pipeline" 的顶级 trace,下面挂着 "Search Knowledge Base" 和 "Chat Completion" 两个子 trace,整个调用链的输入输出和耗时都被完整记录。
python
from langsmith import traceable
from openai import OpenAI
client = OpenAI()
@traceable(run_type="llm", name="Chat Completion")
def call_model(messages: list[dict]):
"""调用 LLM 的包装函数,被标记为 LLM 类型的 trace。"""
response = client.chat.completions.create(
model="gpt-4.1",
messages=messages
)
return response.choices[0].message
@traceable(run_type="tool", name="Search Knowledge Base")
def search_knowledge_base(query: str) -> str:
"""搜索知识库的工具函数,被标记为 tool 类型。"""
# ... 实际搜索逻辑
return f"搜索结果: {query}"
@traceable(name="Research Pipeline")
def research_pipeline(topic: str):
"""顶层函数:调用搜索工具和模型,自动形成三级 trace 树。"""
context = search_knowledge_base(topic)
messages = [
{"role": "system", "content": "你是一名研究助理。"},
{"role": "user", "content": f"主题: {topic}\n上下文: {context}"}
]
return call_model(messages)
result = research_pipeline("量子计算")
print(result.content)
理解 run_type 和 metadata 这两个参数的含义对用好 LangSmith 至关重要。run_type 决定了 LangSmith 如何在 UI 中渲染这条 trace,"llm" 类型会触发 token 计数和成本估算的自动展示,让你一眼看到这次调用消耗了多少 token、花了多少钱,"tool" 类型会结构化展示工具的输入参数和返回结果,方便排查工具调用异常,"retriever" 类型则会渲染检索到的文档列表,让你直观判断检索质量,不指定时默认为 "chain",作为通用的执行步骤展示。metadata 参数允许你附加自定义的键值对,用户 ID、会话 ID、功能模块名称、AB 实验分组,这些信息可以在 LangSmith 控制台中用于筛选和分组,让成本分析能够按用户、按功能、按时间段灵活切分。
对于不在 LangChain 标准模型列表中的自定义模型或自部署模型,LangSmith 无法自动获取 token 计数和定价,需要你手动提供。下面的代码演示了完整的自定义成本上报流程,在 @traceable 的 metadata 中设置 ls_provider 和 ls_model_name 来告诉 LangSmith 这是什么模型,然后在函数体内通过 get_current_run_tree() 获取当前运行的 RunTree 对象,将 usage_metadata(包含输入成本、输出成本以及可选的缓存读取成本等细分项,单位均为美元)附加上去。这样即便是一个完全自研的推理服务,也能在 LangSmith 中获得与其他模型一致的成本视图。
python
from langsmith import traceable, get_current_run_tree
@traceable(
run_type="llm",
metadata={"ls_provider": "my_provider", "ls_model_name": "my_model"}
)
def custom_model_call(messages: list):
# ... 实际的模型调用逻辑
run = get_current_run_tree()
run.set(usage_metadata={
"input_cost": 1.1e-6, # 输入成本(美元)
"output_cost": 5.0e-6, # 输出成本(美元)
"input_cost_details": { # 细分的输入成本
"cache_read": 2.3e-7
}
})
return {"role": "assistant", "content": "..."}
在生产环境中你通常不希望所有请求都开启 trace,开发调试时的频繁调用会产生大量噪音数据,后台健康检查的周期性请求也没有追踪价值。LangSmith 提供了 tracing_context 上下文管理器来解决这个问题。下面的代码展示了如何在代码块级别精细控制追踪开关,with ls.tracing_context(enabled=True) 包裹的代码段一定会被追踪(无论全局环境变量如何设置),而 tracing_context 之外的调用则遵循 LANGSMITH_TRACING 环境变量的控制。这种方式让你可以在同一个进程中同时运行"需要追踪的关键业务请求"和"不需要追踪的后台请求",互不干扰。
python
import langsmith as ls
# 这段代码的调用会被追踪
with ls.tracing_context(enabled=True):
agent.invoke({"messages": [{"role": "user", "content": "重要业务请求"}]})
# 这段不会(假设没有设置 LANGSMITH_TRACING 环境变量)
agent.invoke({"messages": [{"role": "user", "content": "普通请求"}]})
掌握了并行处理和成本监控之后,Agent 的速度和花费都已经在你的掌控之中。但还有一个隐蔽的问题潜伏在长对话场景中,随着对话轮次增加,累积的消息历史会持续膨胀,推高延迟和成本的同时,甚至可能撑爆模型的上下文窗口。这个问题不能靠"更快"或"更省"来解决,它需要一种根本性的策略,主动管理上下文,而不是被动地对历史照单全收。
上下文压缩策略:不让 Agent 被历史淹没
LLM 的上下文窗口虽然在持续扩大,从早期的 4096 token 到如今的 128K 甚至 200K,但这并不意味着你应该把全部历史一股脑塞给模型。即便技术上不超限,过长的上下文也会带来三个实际问题,模型对"中间"信息的注意力会稀释(学术上称为"迷失在中间"效应),推理延迟随上下文长度线性甚至超线性增长,以及每次调用的 token 成本按照上下文长度直接结算。对话式 Agent 是上下文膨胀的重灾区,在二十轮交互后消息历史可能轻松积累上万 token,而其中前十五轮的寒暄和初步探索很可能对当前任务毫无价值,却和关键信息一样被平等地送入模型。
管理上下文的第一个策略是消息裁剪,这是最简单也最直接的方式。在每次调用模型之前,你只保留最近的 N 条消息,将更早的历史直接丢弃。这种方式的优点是零额外开销,不需要额外的 LLM 调用,不需要复杂的逻辑判断,就是粗暴地截断。缺点同样明显,如果用户在对话早期提到了一个关键信息(比如"我的预算是 5000 元"),而裁剪后这条信息被丢弃了,Agent 会失去这个约束条件,可能给出超出预算的建议。因此消息裁剪更适合那些对话主题频繁切换、历史信息几乎没有跨轮次价值的场景,比如客服机器人的单次 FAQ 查询。
消息裁剪虽然简单,但"保留最近 N 条"这个一刀切的规则在面对需要跨轮次记忆的复杂任务时显得过于粗暴。有没有一种方法,既压缩了 token 数量,又保留了历史中真正有价值的信息?这就引出了第二个策略,摘要压缩 。LangChain 在 v1 版本中将摘要压缩正式内置为中间件,即 SummarizationMiddleware。它的工作原理是,持续监控消息的 token 数量,一旦超过你设定的触发阈值,就自动调用一个独立的(通常更便宜的)摘要模型,把较早期的消息压缩成一段精炼的摘要文本,并用这段摘要替换掉原始消息。这样一来,Agent 看到的上下文从"原始的多轮对话"变成了"一段摘要 + 最近的几轮对话",在显著降低 token 消耗的同时保留了历史中的关键语义。
下面的代码展示了 SummarizationMiddleware 的四种典型配置,其中蕴含了两个重要的实战细节,理解它们能帮你避开实际开发中的常见陷阱。
第一个细节关乎 fraction(上下文占比)模式。当你使用 trigger=("fraction", 0.8) 表示"上下文窗口占用了 80% 时触发压缩"时,中间件需要知道模型的最大上下文长度才能计算出绝对 token 阈值------它内部会读取 model.profile["max_input_tokens"] 来获取这个值。LangChain 内置集成的主流模型(如 OpenAI 的 gpt-5.5、Anthropic 的 claude 系列)会自动携带 profile 信息,因此 fraction 模式可以开箱即用。但当你通过 SiliconFlow 等第三方 API 调用 Qwen、DeepSeek 等开源模型时,init_chat_model 返回的模型对象不会自动包含 max_input_tokens,fraction 模式将因无法计算阈值而静默失效。解决办法是在初始化模型时手动传入 profile 字典,如代码中 main_model 的 profile={"max_input_tokens": 131072}(Qwen3.6-27B 支持 128K 上下文)和 summary_model 的 profile={"max_input_tokens": 32768}(Qwen3.5-4B 支持 32K 上下文)。如果你忘记这一步而使用了 fraction 触发,中间件不会报错,但摘要永远不会被触发,这是一个排查起来相当耗时的隐蔽问题。
第二个细节关于触发条件的组合逻辑。SummarizationMiddleware 原生支持两种触发方式:传入单个元组如 ("tokens", 4000) 表示单一阈值;传入元组列表如 [("tokens", 3000), ("messages", 6)] 表示 OR 逻辑 ,列表中任一条件满足即触发压缩。但原生 API 并不支持 AND 逻辑(所有条件必须同时满足才触发),如果你的场景需要更保守的策略,比如既要求 token 超过阈值,又要求消息积累到一定数量,避免对话刚热起来就过早压缩,就需要子类化 SummarizationMiddleware 并覆写 _should_summarize 方法 。代码中的 AndSummarizationMiddleware 正是这样一个子类,它遍历 _trigger_conditions 中的所有条件类型,对 messages 类比较消息数量、对 tokens 类比较 token 数量、对 fraction 类则先通过 _get_profile_limits() 获取模型上下文上限再换算为绝对阈值,只有当所有条件都达标时才返回 True,其中任意一个不满足就返回 False。agent3 使用这个子类实现了"token 数达到 4000 且消息数达到 10 条才触发"的 AND 语义,比原生 OR 模式更加克制。
代码中的四种配置依次演示了:基础的单条件触发(agent)、原生 OR 多条件触发(agent2)、通过子类实现的 AND 触发(agent3)、以及需要手动指定 profile 才能生效的上下文占比触发(agent4)。
python
from langchain.agents import create_agent
from langchain.agents.middleware import SummarizationMiddleware
from langchain.chat_models import init_chat_model
from langchain_core.messages import AnyMessage
from dotenv import load_dotenv
load_dotenv()
# 初始化主模型和摘要模型(需要显式指定 model_provider)
# fraction 模式需要模型 profile 信息,手动指定 max_input_tokens
main_model = init_chat_model(
"Qwen/Qwen3.6-27B", model_provider="openai",
profile={"max_input_tokens": 131072}, # Qwen3.6-27B 支持 128K 上下文
)
summary_model = init_chat_model(
"Qwen/Qwen3.5-4B", model_provider="openai",
profile={"max_input_tokens": 32768}, # Qwen3.5-4B 支持 32K 上下文
)
# ============================================================
# 自定义 AND 逻辑的 SummarizationMiddleware
# ============================================================
class AndSummarizationMiddleware(SummarizationMiddleware):
"""所有触发条件同时满足时才触发摘要(AND 逻辑)。"""
def _should_summarize(self, messages: list[AnyMessage], total_tokens: int) -> bool:
if not self._trigger_conditions:
return False
for kind, value in self._trigger_conditions:
if kind == "messages" and len(messages) < value:
return False
if kind == "tokens" and total_tokens < value:
return False
if kind == "fraction":
max_input_tokens = self._get_profile_limits()
if max_input_tokens is None:
return False
threshold = int(max_input_tokens * value)
if total_tokens < threshold:
return False
return True
# 基本用法,token超过 4000 时压缩,保留最近 20 条消息
agent = create_agent(
model=main_model,
middleware=[
SummarizationMiddleware(
model=summary_model,
trigger=("tokens", 4000),
keep=("messages", 20),
)
],
)
# 多条件 OR 触发(原生支持),token >= 3000 或消息 >= 6 条时触发
agent2 = create_agent(
model=main_model,
middleware=[
SummarizationMiddleware(
model=summary_model,
trigger=[("tokens", 3000), ("messages", 6)],
keep=("messages", 20),
)
],
)
# AND 触发:需要 token >= 4000 且消息 >= 10 条才触发
agent3 = create_agent(
model=main_model,
middleware=[
AndSummarizationMiddleware(
model=summary_model,
trigger=[("tokens", 4000), ("messages", 10)], # 所有条件同时满足才触发
keep=("messages", 20),
)
],
)
# 使用上下文占比,使用率达到 80% 时触发,压缩到 30%
agent4 = create_agent(
model=main_model,
middleware=[
SummarizationMiddleware(
model=summary_model,
trigger=("fraction", 0.8),
keep=("fraction", 0.3),
)
],
)
除了触发逻辑和 profile 配置这两个细节,SummarizationMiddleware 还有一个值得注意的局限,它只压缩文本消息。如果历史消息中包含多模态内容(图片、音频等),这些消息不会被压缩处理,它们要么在 keep 参数保留的最近消息中得以原样保留,要么在落入摘要区域时丢失视觉信息(摘要只包含文本描述)。对于图片密集型应用,推荐的做法是将媒体文件存储到外部文件系统或对象存储中,在消息里只传递 URL 或文件引用,这样摘要可以保留这些引用的文本描述,Agent 需要时再按 URL 加载实际内容。
理解了裁剪和摘要各自的优劣之后,一个自然的想法是把两者结合起来,这就形成了第三个策略,混合策略 。具体做法是,对最近的 N 条消息保持原样不处理(保证当前对话上下文完整,不打断正在进行的推理),对中间层的消息用摘要模型压缩(保留语义但大幅减少 token),对最早期且显然不再相关的消息直接丢弃。在 LangChain 的 SummarizationMiddleware 中,trigger 参数控制"什么时候该动手",keep 参数控制"动手后保留多少原始消息",这本身就是一种混合策略的实现,keep=("messages", 20) 表示始终保留最近 20 条消息的原文不受影响,更早的消息在被摘要替换的同时本质上也被丢弃了。你还可以通过自定义 summary_prompt 参数来注入领域知识,例如在 prompt 中特别要求摘要模型"保留所有数字、日期和人名",这样能在大幅减少 token 的同时最大化信息保真度。
除了这三种核心策略,LangGraph 生态系统还提供了几个更细粒度的上下文管理工具,你可以根据场景按需选用。trim_messages 是一种在每次调用模型前动态裁剪消息的轻量方案,按消息列表的开头或末尾移除消息直到满足 token 上限,适合一次性批量处理任务。更激进的方案是从 LangGraph 的状态中直接 delete_messages,这会永久删除指定消息,后续所有轮次都无法再看到它们,适用于子任务已经完全处理完毕、相关消息不再有保留价值的场景。此外在 Deep Agents 中还引入了 offloading 机制,当工具返回的结果数据量巨大时(比如一次数据库查询返回了上千行),数据被写入文件系统而消息中只保留一个文件引用路径,Agent 需要时再按需读取,这在处理大型 CSV、日志文件或代码库分析时尤其有效。
下表将三种核心上下文压缩策略的设计取舍做了横向对比,方便你在不同场景下快速选型,
| 策略 | 触发条件 | 优点 | 适用场景 |
|---|---|---|---|
| 固定阈值(消息裁剪) | Token 数 > 阈值 或 消息数 > 阈值 | 实现简单,零额外 LLM 调用开销 | 对话主题频繁切换,历史信息价值低 |
| 任务边界(摘要压缩) | 子任务完成时 / 阶段性节点 | 保留语义信息,不打断当前思考 | 多步推理、长对话中需要跨轮次记忆 |
| 混合策略(裁剪 + 摘要) | 阈值触发 + 保留最近 N 条原文 | 兼顾效率与信息保真,最优用户体验 | 绝大多数生产环境中的对话 Agent |
练习任务
- 实现批量并行处理,对比串行耗时
- 配置 LangSmith 监控追踪
- 对比不同上下文压缩策略的效果
考核点 ✅
- 性能对比:提交 batch 并行 vs 串行处理的耗时对比数据(用 time 模块记录)
- 监控接入 :提交含
@traceable装饰器的函数代码,说明 LangSmith 追踪原理 - 压缩策略:提交 3 种上下文压缩策略的对比表(触发条件/优点/适用场景)
- 优化总结:提交第3周的优化建议清单(至少 5 条针对自己代码的改进点)