# 图结构应用程序删除消息的使用技巧

# 01. 更新删除与归纳函数

在图结构应用程序中,消息列表是一种高频使用的状态,通常情况下我们只会往状态中添加消息。但是在某些特殊的情况下,我们可能希望删除消息列表中的某一条消息(亦或者是修改消息列表中的某一条数据)。

这个时候就需要使用 LangGraph 为我们提供的 RemoveMessage 装饰符配合 add_messages() 函数一起来实现这个功能。其核心思想是归纳函数 add_messages() 底层针对更新的消息类型做了检测,如果检测到是 RemoveMessage 类型,则不会新增数据,而是执行删除数据的操作。

所以对于需要删除的消息,只需要在节点返回的时候,创建 RemoveMessage 实例并传递 消息id 即可,例如下方提问后删除人类消息:

from typing import Any

import dotenv
from langchain_core.messages import RemoveMessage
from langchain_core.runnables import RunnableConfig
from langchain_openai import ChatOpenAI
from langgraph.graph import MessagesState, StateGraph

dotenv.load_dotenv()

llm = ChatOpenAI(model="gpt-4o-mini")


def chatbot(state: MessagesState, config: RunnableConfig) -> Any:
    """聊天机器人节点"""
    return {"messages": [llm.invoke(state["messages"])]}


def delete_human_message(state: MessagesState, config: RunnableConfig) -> Any:
    """删除人类消息节点"""
    human_message = state["messages"][0]
    return {"messages": [RemoveMessage(id=human_message.id)]}



graph_builder = StateGraph(MessagesState)


graph_builder.add_node("chatbot", chatbot)
graph_builder.add_node("delete_human_message", delete_human_message)


graph_builder.set_entry_point("chatbot")
graph_builder.add_edge("chatbot", "delete_human_message")
graph_builder.set_finish_point("delete_human_message")


graph = graph_builder.compile()


print(graph.invoke({"messages": [("human", "你好,你是")]}))
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41

输出内容:

{'messages': [AIMessage(content='你好!我是一个人工智能助手,随时准备帮助你。有什么我可以为你做的吗?', response_metadata={'token_usage': {'completion_tokens': 22, 'prompt_tokens': 10, 'total_tokens': 32}, 'model_name': 'gpt-4o-mini', 'system_fingerprint': 'fp_80a1bad4c7', 'finish_reason': 'stop', 'logprobs': None}, id='run-7d294d12-4769-4cb8-be6e-cf4f3f5af007-0', usage_metadata={'input_tokens': 10, 'output_tokens': 22, 'total_tokens': 32})]}
1

在上述的代码中,其运行逻辑是由 归纳函数 进行处理判断的,核心代码如下:

for m in right:
    if (existing_idx := left_idx_by_id.get(m.id)) is not None:
        if isinstance(m, RemoveMessage):
            ids_to_remove.add(m.id)
        else:
            merged[existing_idx] = m
    else:
        if isinstance(m, RemoveMessage):
            raise ValueError(
                f"Attempting to delete a message with an ID that doesn't exist ('{m.id}')"
            )

        merged.append(m)
merged = [m for m in merged if m.id not in ids_to_remove]
1
2
3
4
5
6
7
8
9
10
11
12
13
14

在这段代码中,会检测右侧传递的消息列表,并检测类型是否为 RemoveMessage ,如果是的话,则在合并的时候会剔除数据,如果不是的话则会覆盖,所以利用这个技巧也可以修改原来的消息数据,例如添加如下代码:

def update_ai_message(state: MessagesState, config: RunnableConfig) -> Any:
    """修改AI消息节点"""
    ai_message = state["messages"][-1]
    return {"messages": [AIMessage(id=ai_message.id, content="我是被修改过后的AI消息:" + ai_message.content)]}

graph_builder.add_node("update_ai_message", update_ai_message)

graph_builder.add_edge("delete_human_message", "update_ai_message")
graph_builder.set_finish_point("update_ai_message")
1
2
3
4
5
6
7
8
9

输出内容:

{'messages': [AIMessage(content='我是被修改过后的AI消息:你好!我是一个AI助手,有什么我可以帮助你的吗?', id='run-03b043ff-8485-44b4-aadf-b713fd11b352-0')]}
1

这也是为什么使用 LangGraph 提供的 add_messages 而不是 operator.add 来实现, operator.add 虽然能实现对列表的相加,但没有针对修改亦或者删除的逻辑,仍然需要手动去实现,因为本质上这里的删除/更新逻辑是 归纳函数 实现的,所以对于没有配置归纳函数,或者归纳函数没有该逻辑的则无法实现。

[!IMPORTANT] 删除消息一定要特别注意,因为绝大部分模型期望消息列表存在某些规则。例如,有些模型期望它们以 user 消息开头,其他模型期望所有带有工具调用的消息后面都跟着工具消息。 删除消息时,需要确保不会违反这些规则。

# 02. 过滤与修剪消息

在 LangGraph 中 状态 可以很便捷管理整个过程中产生的所有消息信息,但是随着持续对话,亦或者图结构组件的增加,对话历史会不断累积,并占用越来越多的上下文窗口,这通常是不可取的,因为它会导致对 LLM 的调用变得非常昂贵和耗时,并降低 LLM 生成内容的正确性,所以在 LangGraph 中一般还需要对消息进行过滤和修剪。

过滤/修剪 一般不会更改 状态 ,而是在调用 LLM 时,只传递特定条数的消息或者按照 token长度 进行修剪。

例如使用 过滤消息 可以单独创建一个函数(非节点),在调用 LLM 前,对消息进行过滤,使用固定条数的 消息列表 :

def filter_messages(state: MessagesState) -> Any:
    """过滤数据状态并返回最后一条消息"""
    return state["messages"][-1:]

def chatbot(state: MessagesState, config: RunnableConfig) -> Any:
    """聊天机器人节点"""
    messages = filter_messages(state)
    return {"messages": [llm.invoke(messages)]}
1
2
3
4
5
6
7
8

这样在使用 LLM 时就可以避免全部将消息传递过去,并且在图架构程序内, 状态 仍然会保存最完整的信息。

除此之外,还可以依据 Token长度限制 对消息列表进行修剪,在 LangChain 中对于该需求还封装了特定的函数 trim_messages ,该函数的参数如下:

  • messages :需要修剪的消息列表。

  • max_tokens :修剪消息的最大 Token 数。

  • strategy :修剪策略, first 代表从前往后修剪消息, last 代表从后往前修剪消息,默认为 last 。

  • token_counter :计算 Token 数的函数,或者传递大语言模型(使用大语言模型的 .get_num_tokens_from_messages() 计算 Token 数)。

  • allow_partial :如果只能拆分消息的一部分,是否拆分消息,默认为 False ,拆分可以分成多种,一种是消息文本单独拆分,另外一种是如果设置了 n ,一次性返回多条消息,针对消息的拆分。

  • end_on :修剪消息结束的类型,如果执行,则在这种类型的最后一次出现时将被忽略,类型为列表或者单个值(支持传递消息的类型字符串,例如:system、human、ai、tool 等,亦或者传递消息类)。

  • start_on :修剪消息开始的类型,如果执行,则在这种类型的最后一次出现时将被忽略,类型为列表或者单个值(支持传递消息的类型字符串,例如:system、human、ai、tool 等,亦或者传递消息类)。

  • include_system :是否保留系统消息,只有在 strategy="last" 时设置才有效。

  • text_splitter :文本分割器,默认为空,当设置 allow_partial=True 时才有用,用于对某个消息类型中的大文本进行分割。

例如实现对消息列表进行修剪,使其 Token 数不超过 80,保留前置消息,允许部分分割,使用的模型为 gpt-4o-mini ,代码如下:

import dotenv
from langchain_core.messages import HumanMessage, AIMessage, trim_messages
from langchain_openai import ChatOpenAI
from langchain_text_splitters import RecursiveCharacterTextSplitter

dotenv.load_dotenv()

messages = [
    HumanMessage(content="你好,我叫慕小课,我喜欢游泳打篮球,你喜欢什么呢?"),
    AIMessage([
        {"type": "text", "text": "你好,慕小课!我对很多话题感兴趣,比如探索新知识和帮助解决问题。你最喜欢游泳还是篮球呢?"},
        {
            "type": "text",
            "text": "你好,慕小课!我喜欢探讨各种话题和帮助解答问题。你对游泳和篮球的兴趣很广泛,有没有特别喜欢的运动方式或运动员呢?"
        },
    ]),
    HumanMessage(content="如果我想掌握关于天体物理方面的知识,你能给我一些建议么?"),
    AIMessage(
        content="当然可以!你可以从基础的天文学和物理学入手,然后逐步深入到更具体的天体物理领域。阅读相关的书籍,如《宇宙的结构》或《引力的秘密》,也可以关注一些优秀的天体物理学讲座和资料。你对哪个方面最感兴趣?"
    ),
]

llm = ChatOpenAI(model="gpt-4o-mini")

update_messages = trim_messages(
    messages,
    max_tokens=80,
    token_counter=llm,
    strategy="first",
    allow_partial=True,
    text_splitter=RecursiveCharacterTextSplitter(),
)

print(update_messages)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34

输出内容:

[HumanMessage(content='你好,我叫慕小课,我喜欢游泳打篮球,你喜欢什么呢?'), AIMessage(content=[{'type': 'text', 'text': '你好,慕小课!我对很多话题感兴趣,比如探索新知识和帮助解决问题。你最喜欢游泳还是篮球呢?'}])]
1

并且在 LangChain 中 trim_messages() 函数使用 @_runnable_support 装饰器进行装饰,所以该函数也是一个 Runnable 可运行组件,可以直接拼接到 LCEL 表达式构建的链应用中。

[!IMPORTANT] 课后思考:结合 摘要记忆组件 的思想,实现一个能同时记录 历史对话摘要 和 最近N条消息 的 Graph 程序,当 Token 数不超过 1000 时,使用消息列表,当 Token 数超过 1000 时,使用 历史对话摘要 + Token数不超过1000的N条消息 作为 LLM 的输入,请思考使用 数据状态+Graph 的方式实现(非必须实现,课外思考)。

# 最新版 LangChain 用法提示

  • 新版 LangChain 更推荐用 LCEL、Runnable、ChatModel.bind_tools()、结构化输出和 LangGraph 来组织复杂链路;老式 Chain、部分 AgentExecutor 写法可以读懂,但新项目应优先选择更清晰的图或 Runnable 编排。

  • 工具调用相关代码要区分两层:模型是否原生支持 tool/function calling,以及业务侧如何定义工具 schema、参数校验、错误兜底和观测日志。

  • 如果示例中的导入路径和你当前安装版本不同,优先查当前版本包内导出位置;常见迁移方向是从 langchain 拆到 langchain-core、langchain-community、langchain-openai、langgraph 等包。

# 拓展

  • 工具或插件不要只看能不能调通,更要看是否可观测、可限流、可重试、可审计。联网类工具还要处理超时、空结果、搜索噪声和结果时效性。

  • Agent 场景里,Prompt 只是调度策略的一部分;工具描述、参数 schema、历史状态、错误反馈、停止条件和人工介入点同样会影响最终稳定性。

# 常见问题

  • 为什么模型没有调用工具?常见原因是工具描述不清晰、参数 schema 过宽或过窄、用户问题不需要工具、模型本身不支持工具调用,或者工具绑定位置不对。

  • 为什么工具调用后回答仍然不准?先看工具返回是否正确,再看工具结果是否被放回模型上下文,最后检查输出解析、历史消息和异常兜底是否覆盖了真实错误。

# 面试题

  • 解释函数调用、工具调用和 Agent 的区别。

  • LangChain 中 tool schema 的作用是什么?为什么参数校验对生产环境很重要?

  • ReACT Agent 和 tool-calling Agent 的核心差异是什么?分别适合什么场景?

  • LangGraph 相比 LCEL 更适合解决哪些复杂编排问题?

# 生产问题排查

问题 常见原因 处理方式
工具没有被调用 工具描述弱、绑定失败、模型不支持 打印绑定后的模型配置,补充工具描述,换用支持工具调用的模型
参数格式错误 schema 设计不清晰,模型生成字段不稳定 使用 Pydantic/JSON Schema 校验,失败后把错误反馈给模型重试
联网结果不可用 搜索为空、接口超时、命中低质量页面 增加超时、重试、结果过滤、来源白名单和降级回答
Agent 循环不停止 缺少终止条件或工具返回被误判 设置最大迭代次数,记录每轮 thought/action/observation,增加停止规则
线上难以复现 缺少输入、工具请求和模型响应日志 给每次调用加 trace id,记录工具入参、出参、耗时和异常