第09章:上下文与记忆 (4)

记忆治理策略(上下文管理)

随着对话的进行,历史消息不断累积, state会持续增长 ,为模型带来挑战:

  • LLM的 上下文窗口是有限的 ,完整历史可能无法装入LLM的上下文窗口,导致上下文丢失或错 误。
  • .即便模型的上下文窗口够大,多数LLM在长上下文场景仍然表现不佳。模型会 被陈旧或离题的内 容"分散注意力"
  • 同时,会带来 高昂的token花费

此时需要对上下文进行管理:对历史记录进行压缩、清理、重组等。

1 消息裁剪

调用模型前裁剪上下文。

目标是控制token用量,通常 保留系统初始消息和最近若干消息 ,或 按token数保留末尾内容 。 适合成本敏感、对旧上下文依赖不强的场景。

复制代码
from langchain.agents import create_agent, AgentState
from langgraph.checkpoint.memory import InMemorySaver
from langchain_core.runnables import RunnableConfig
from langchain_core.messages import HumanMessage
from typing import Any
from langchain.agents.middleware import before_model
from langgraph.graph.message import REMOVE_ALL_MESSAGES
from langchain.messages import RemoveMessage
from langgraph.runtime import Runtime

from dotenv import load_dotenv


load_dotenv(verbose=True)


# 在模型调用之前,对消息历史进行裁剪
@before_model
def trim_messages(
    state: AgentState,
    runtime: Runtime
) -> dict[str, Any] | None:

    messages = state["messages"]

    # 消息数量不超过 3 条时,不进行裁剪
    if len(messages) <= 3:
        return None

    # 始终保留第一条消息
    first_message = messages[0]

    # 根据消息数量的奇偶性,保留最近的消息
    if len(messages) % 2 == 0:
        second_message = messages[-3:]
    else:
        second_message = messages[-4:]

    # 第一条消息 + 最近的消息
    new_message = [first_message] + second_message

    return {
        "messages": [
            # 删除当前所有消息
            RemoveMessage(id=REMOVE_ALL_MESSAGES),

            # 添加裁剪后的消息
            *new_message
        ]
    }


# 1. 创建内存级别的会话记忆
checkpointer = InMemorySaver()


# 2. 创建 Agent
agent = create_agent(
    model="deepseek:deepseek-v4-pro",
    tools=[],
    checkpointer=checkpointer,
    middleware=[trim_messages],
)


# 3. 指定会话 ID
# 相同的 thread_id 会使用同一份会话历史
config: RunnableConfig = {
    "configurable": {
        "thread_id": "1"
    }
}


# 4. 第一次调用
agent.invoke(
    {
        "messages": [
            HumanMessage("你好,我是老王")
        ]
    },
    config
)


# 5. 第二次调用
agent.invoke(
    {
        "messages": [
            HumanMessage("从现在起,你叫小王")
        ]
    },
    config
)


# 6. 第三次调用
agent.invoke(
    {
        "messages": [
            HumanMessage("今天天气不错")
        ]
    },
    config
)


# 7. 第四次调用
# 此时 before_model 中的 trim_messages 会检查并裁剪历史消息
final_response = agent.invoke(
    {
        "messages": [
            HumanMessage("告诉我,你是谁?我是谁?")
        ]
    },
    config
)


# 8. 输出最终消息
for msg in final_response["messages"]:
    msg.pretty_print()

假设有 5 条消息: H1, A1, H2, A2, H3 (当前总数 5,奇数)。

执行 messages-4: 取出 A1, H2, A2, H3 。加上第一条 H1 ,最终大模型看到的是 H1, A1, H2, A2, H3 (相当于没剪掉什么)。

分析:

2 消息删除

消息裁剪强调"在 模型调用前裁剪 消息列表,控制模型可以看到的上下文范围",而消息删除强调 模型 调用完成后将某些消息从消息列表中移除 ,永久更改状态。

适合明确要遗忘、清理、重置某些历史

复制代码
from langchain.agents import create_agent, AgentState
from langgraph.checkpoint.memory import InMemorySaver
from langchain_core.runnables import RunnableConfig
from langchain_core.messages import HumanMessage

from langchain.agents.middleware import after_model
from langchain.messages import RemoveMessage
from langgraph.runtime import Runtime

from dotenv import load_dotenv


load_dotenv(verbose=True)


@after_model
def delete_old_messages(
    state: AgentState,
    runtime: Runtime
) -> dict | None:

    messages = state["messages"]

    # 保持最近的 5 条消息
    if len(messages) > 5:

        # 计算需要删除的消息数量
        to_delete = len(messages) - 5

        # 删除最老的消息
        return {
            "messages": [
                RemoveMessage(id=m.id)
                for m in messages[:to_delete]
            ]
        }

    return None


# 1. 创建内存级别的会话记忆
checkpointer = InMemorySaver()


# 2. 创建 Agent
agent = create_agent(
    model="deepseek:deepseek-v4-pro",
    tools=[],
    checkpointer=checkpointer,
    middleware=[delete_old_messages],
)


# 3. 配置会话 ID
config: RunnableConfig = {
    "configurable": {
        "thread_id": "1"
    }
}


# 4. 第一次调用
agent.invoke(
    {
        "messages": [
            HumanMessage("你好,我是老王")
        ]
    },
    config
)


# 5. 第二次调用
agent.invoke(
    {
        "messages": [
            HumanMessage("从现在起,你叫小王")
        ]
    },
    config
)


# 6. 第三次调用
agent.invoke(
    {
        "messages": [
            HumanMessage("今天天气不错")
        ]
    },
    config
)


# 7. 第四次调用
final_response = agent.invoke(
    {
        "messages": [
            HumanMessage("告诉我,你是谁?我是谁?")
        ]
    },
    config
)


# 8. 输出最终消息
for msg in final_response["messages"]:
    msg.pretty_print()

说明:只要消息总数超过 5 条,就计算超出几条( to_delete ),然后精准地删掉最老的那几条,使剩 下的消息总数永远保持在 5 条。

分析消息列表 messages 的数量变化:

📥 第一轮:

用户说:"你好,我是老王"(第 1 条)

AI 回复:"你好,老王!"(第 2 条)

数量:此时 len(messages) == 2,不大于 5。

结果:中间件不触发。

记忆:记住你是老王。

📥 第二轮:

用户说:"从现在起,你叫小王"(第 3 条)

AI 回复:"好的,我是小王。"(第 4 条)

数量:此时 len(messages) == 4,不大于 5。

结果:中间件不触发。

记忆:记住你是老王、我是小王。

📥 第三轮:

用户说:"今天天气不错"(第 5 条)

AI 回复:"是啊,天气好心情也好。"(第 6 条)

数量:此时 len(messages) == 6,大于 5。

结果:触发 after_model 中间件!

清理发生:

to_delete = len(messages) - 5

= 6 - 5

= 1

清理:

messages:1

也就是删除第 1 条消息:

用户说:"你好,我是老王"

清理后剩余 5 条:

  1. AI 回复:"你好,老王!"(第 2 条)

  2. 用户说:"从现在起,你叫小王"(第 3 条)

  3. AI 回复:"好的,我是小王。"(第 4 条)

  4. 用户说:"今天天气不错"(第 5 条)

  5. AI 回复:"是啊,天气好心情也好。"(第 6 条)

📥 第四轮(最终提问):

用户说:"告诉我,你是谁?我是谁?"(第 7 条)

在模型生成最终回复之前:

当前历史消息有 5 条:

  1. AI 回复:"你好,老王!"

  2. 用户:"从现在起,你叫小王"

  3. AI 回复:"好的,我是小王。"

  4. 用户:"今天天气不错"

  5. AI 回复:"是啊,天气好心情也好。"

再加上本轮用户的新消息:

  1. 用户:"告诉我,你是谁?我是谁?"

大模型根据上下文可以推断:

"你好,老王!"

所以仍然能够知道:

用户 = 老王

AI = 小王

AI 回复:

"你是老王,我是小王啊。"

此时消息数量:

len(messages) == 8

再次触发 after_model 中间件!

清理发生:

to_delete = 8 - 5

= 3

删除:

messages:3

也就是删除当前最老的 3 条消息。

最终保留最近 5 条:

  1. AI 回复:"好的,我是小王。"(第 4 条)

  2. 用户:"今天天气不错"(第 5 条)

  3. AI 回复:"是啊,天气好心情也好。"(第 6 条)

  4. 用户:"告诉我,你是谁?我是谁?"(第 7 条)

  5. AI 回复:"你是老王,我是小王啊。"(第 8 条)

RemoveMessage到底干了什么?

当你在中间件里返回 RemoveMessage(id=m.id) 时,你实际上是向框架发送了一个 删除指令 。 框架的底层处理逻辑如下:


历史消息池 (内存中持续存在)

├── Message(id="1", content="你好,我是老王")

├── Message(id="2", content="...")

└── RemoveMessage(id="1") <-- 这是一个新追加进去的"墓碑"标记

1.追加"墓碑"标记:框架收到 RemoveMessage(id="1") 后,并不会去内存的数组里把 id="1" 的 对象删掉,而是把这个 RemoveMessage 作为一条新记录追加到当前线程的状态历史中。这个 RemoveMessage 就像是一个"墓碑"。

  1. 运行时过滤合并(Reducer):当下一次你再次调用 agent.invoke 或者大模型要去读取上下文 时,框架的内置合并器(Reducer)会把"原始消息"和"墓碑标记"放在一起进行计算:

它在丢给大模型之前,会自动把被标记删除的消息过滤掉。

3 摘要

把早期历史压缩成摘要,再替换原始消息。

消息裁剪和删除都会导致上下文缺失,影响回答质量和用户体验。和它们相比,摘要是更适合长会话的 折中方案:保语义,不保原文。官方推荐内置 SummarizationMiddleware 。上一章已有讲解。

复制代码
from langchain.chat_models import init_chat_model
from dotenv import load_dotenv
import os

# 从 .env 文件中加载环境变量
load_dotenv(override=True)


# 用于正常对话的模型
model_out = init_chat_model(
    model="deepseek-v4-flash",
    model_provider="deepseek",
    extra_body={"thinking": {"type": "disabled"}},
)

# 用于生成历史消息摘要的模型

model_in = init_chat_model(
    model="deepseek-v4-pro",
    model_provider="deepseek",
    extra_body={"thinking": {"type": "disabled"}},
)

from langchain.agents.middleware import SummarizationMiddleware
from langchain.agents import create_agent
from langgraph.checkpoint.memory import InMemorySaver


# 创建带摘要中间件的 Agent
agent = create_agent(
    model=model_out,
    tools=[],
    checkpointer=InMemorySaver(),
    middleware=[
        SummarizationMiddleware(
            # 用于生成历史消息摘要的模型
            model=model_in,

            # 触发条件:
            # 当消息 Token 数超过 100 时,触发摘要
            trigger=[
                ("tokens", 100),
            ],

            # 摘要完成后,只保留最近 2 条消息
            keep=("messages", 2),

            # 自定义摘要提示词
            summary_prompt=(
                "对历史消息摘要,消息列表如下\n"
                "{messages}"
            ),
        )
    ]
)


# 指定会话 ID
config = {
    "configurable": {
        "thread_id": "1"
    }
}


print("\n进行多轮对话...")


# 模拟多轮对话
conversations = [
    # 强制撑爆 100 tokens,触发摘要
    "我叫张三,是工程师。这里是一段非常长非常长的废话..." * 20,

    # 第二轮:询问之前的信息
    "请总结一下我的信息",
]


for msg in conversations:

    response = agent.invoke(
        {
            "messages": [
                {
                    "role": "user",
                    "content": msg
                }
            ]
        },
        config=config
    )

    # 输出本次调用的消息
    for msg in response["messages"]:
        msg.pretty_print()

    print("*" * 50)

工作原理

常见问题

  1. 摘要会丢失信息吗?

会有一些细节丢失,但:

  • 重要信息会保留(姓名、关键事实)
  • 最近的消息完整保
  • 对于大部分场景足够
  1. 设置最大token数触发摘要的标准是啥?

    模型上下文窗口 4k → 设置 3000

    模型上下文窗口 8k → 设置 6000

    模型上下文窗口 16k → 设置 12000

    留一些余量给工具调用和系统提示

  2. 摘要成本高吗?

  • 摘要只在超过阈值时触发
  • 可以使用便宜的模型(如 gpt-4o-mini)
  • 相比传输全部历史,通常更便宜
  1. 摘要触发的频率要关注吗?

要关注,根据监控摘要触发频率,调整阈值

  • 如果频繁触发 → 提高阈值
  • 如果从不触发 → 降低阈值

4 自定义过滤策略

通过 中间件 可以随意更改消息列表,因此可以实现任意的过滤策略。

5 了解:state的理解

state是agent底层有状态运行图的状态信息,是AgentState类型的实例,定义如下:

复制代码
class AgentState(TypedDict, Generic[ResponseT]):
    """State schema for the agent."""

    # 消息列表
    messages: Required[
        Annotated[
            list[AnyMessage],
            add_messages
        ]
    ]

    # 控制 Agent 是否跳转到其他节点
    jump_to: NotRequired[
        Annotated[
            JumpTo | None,
            EphemeralValue,
            PrivateStateAttr
        ]
    ]

    # 结构化输出
    structured_response: NotRequired[
        Annotated[
            ResponseT,
            OmitFromInput
        ]
    ]

说明:

AgentState是TypedDict的子类,这意味着我们可以按照 字典的读写方式 访问其实例的元素。

该类有三个字段

messages :截止到当前节点的历史会话消息记录,标记为Required。

jump_to :中间件章节介绍过,表示跳转至运行图的指定节点,标记为NotRequired,表示 该字段可以为None。(后续案例可以看到这一点)

structured_response :结构化输出内容,当我们启用结构化输出时,结构化后的内容会被 记录在这里,见下文案例。

复制代码
from langchain.chat_models import init_chat_model
from dotenv import load_dotenv
import os

from langchain.agents import create_agent
from langchain.agents.middleware import (
    AgentState,
    before_model,
    wrap_tool_call,
    after_agent,
)
from langchain.tools.tool_node import ToolCallRequest
from langchain.messages import (
    HumanMessage,
    AIMessage,
    ToolMessage,
)
from langchain.tools import tool
from langgraph.runtime import Runtime
from langgraph.types import Command

from typing import Any, Callable
from pydantic import BaseModel, Field


# 从 .env 文件中加载环境变量
load_dotenv(override=True)


# 初始化模型
model = init_chat_model(
    model="deepseek-v4-flash",
    model_provider="deepseek",
    extra_body={"thinking": {"type": "disabled"}},
)


# ==============================
# 1. 定义结构化输出
# ==============================

class WeatherInfo(BaseModel):
    """城市天气情况"""

    city: str = Field(
        description="城市名称"
    )

    temperature: str = Field(
        description="气温"
    )

    desc: str = Field(
        description="当日天气概述"
    )


# ==============================
# 2. 定义天气工具
# ==============================

@tool(parse_docstring=True)
def get_weather(city: str):
    """
    获取当日天气

    Args:
        city: 城市名称
    """

    return f"[{city}] 今天气温9~16度,万里无云,天气不错适合外出"


# ==============================
# 3. before_model 中间件
# ==============================

@before_model(can_jump_to=["tools"])
def direct_tool_call(
    state: AgentState,
    runtime: Runtime
) -> dict[str, Any] | None:

    last_msg = state["messages"][-1]

    # 如果用户查询北京天气,
    # 直接构造 ToolCall,跳过模型判断
    if (
        isinstance(last_msg, HumanMessage)
        and "天气" in last_msg.text
        and "北京" in last_msg.text
    ):
        fake_tool_call = AIMessage(
            content="人工构造的消息",
            tool_calls=[
                {
                    "name": "get_weather",
                    "args": {
                        "city": "北京"
                    },
                    "id": "direct_call_id",
                }
            ],
        )

        return {
            "messages": [fake_tool_call],
            "jump_to": "tools",
        }

    return None


# ==============================
# 4. wrap_tool_call 中间件
# ==============================

@wrap_tool_call
def first_check(
    request: ToolCallRequest,
    handler: Callable[
        [ToolCallRequest],
        ToolMessage | Command
    ]
) -> ToolMessage | Command:

    print(
        "=" * 30,
        "-> In first_check Middleware <-",
        "=" * 30,
    )

    print(
        f"{request.state.get('jump_to', None) = }"
    )

    print(
        f"{request.state.get('structured_response', None) = }"
    )

    # 继续执行真正的工具调用
    return handler(request)


# ==============================
# 5. after_agent 中间件
# ==============================

@after_agent
def final_check(
    state: AgentState,
    runtime: Runtime
) -> dict[str, Any] | None:

    print(
        "=" * 30,
        "-> In final_check Middleware <-",
        "=" * 30,
    )

    # 打印最终消息
    for msg in state["messages"]:
        msg.pretty_print()

    print(
        "=" * 30,
        "-> 消息打印完毕 <-",
        "=" * 30,
    )

    print(
        f"{state.get('jump_to', None) = }"
    )

    print(
        f"{state.get('structured_response', None) = }"
    )

    return None


# ==============================
# 6. 创建 Agent
# ==============================

agent = create_agent(
    model=model,
    response_format=WeatherInfo,
    middleware=[
        direct_tool_call,
        first_check,
        final_check,
    ],
    tools=[
        get_weather
    ],
)


# ==============================
# 7. 调用 Agent
# ==============================

response = agent.invoke(
    {
        "messages": [
            HumanMessage("请帮我查询北京当日天气")
        ]
    }
)

. 我们在模型调用之前通过jump_to直接跳转至工具节点

. 在工具调用前打印状态信息,此时的jump_to非空

在Agent执行完毕之后打印状态信息,此时的structure_response非空而jump_to为空

============================== -> In first_check Middleware <- ==============================

request.state.get('jump_to', None) = 'tools'

request.state.get('structured_response', None) = None

============================== -> In final_check Middleware <- ==============================

================================ Human Message =================================

请帮我查询北京当日天气

================================== Ai Message ==================================

人工构造的消息

Tool Calls:

get_weather (direct_call_id)

Call ID: direct_call_id

Args:

city: 北京

================================= Tool Message =================================

Name: get_weather

北京 今天气温9~16度,万里无云,天气不错适合外出

================================== Ai Message ==================================

Tool Calls:

WeatherInfo (call_00_zydEw7MDoJfauX6OUoSY8491)

Call ID: call_00_zydEw7MDoJfauX6OUoSY8491

Args:

city: 北京

temperature: 9~16度

desc: 万里无云,天气不错适合外出

================================= Tool Message =================================

Name: WeatherInfo

Returning structured response: city='北京' temperature='9~16度' desc='万里无云,天气不错适合外出'

============================== -> 消息打印完毕 <- ==============================

state.get('jump_to', None) = None

state.get('structured_response', None) = WeatherInfo(city='北京', temperature='9~16度', desc='万里无云,天气不错适合外出')

相关推荐
BangD18 分钟前
visual stdio解决中文乱码问题
服务器
Coodor25 分钟前
使用web也可以写NFC微信小程序拉取
前端·微信小程序·小程序·nfc拉起小程序
wjjzhbb25 分钟前
中小团队敏捷转型:Scrum还是看板?
java·maven·scrum
GodSure091435 分钟前
Java单一职责原则SRP详解
java·python·单一职责原则
lingran__38 分钟前
Linux 基础常用指令万字详解(下)|一切皆文件:重定向、管道、日志 与 Shell 原理
linux·运维·服务器·shell·管道·重定向·打包压缩
探数API小喇叭39 分钟前
基站查询 API 怎么用?LAC、CELLID 参数详解与实战】
java·开发语言·api·基站定位
catino40 分钟前
spring-IOC、DI
java·spring·rpc
祖力5544 分钟前
进程间通信(IPC机制):无名通道与有名通道
linux·运维·服务器·进程·通信·进程间通信·ipc