多轮对话流程梳理详细版本

逐行拆解:本轮消息如何接上一轮(面向 Python 零基础)

下面每一段代码后面都跟三样东西:逐行含义表 、大白话 、在我们项目里会不会触发。


0. 先认识这次请求里的 5 个变量

把整条链路想成 5 个装着消息的箱子,后面所有代码都在倒腾这 5 个箱子:

箱子名 类型 装的什么 出处
input.messages list of AG-UI 消息对象 前端发来的(通常含全量历史 + 本轮新问题) HTTP body
agent_state.values["messages"] list of LangChain 消息对象 Redis 里存的(上一轮结束时) graph.aget_state(config)
state_input["messages"] 同上(同一个对象引用) 把上一个箱子塞进来 代码第 473 行
langchain_messages list of LangChain 消息对象 前端消息转换成 LangChain 对象 代码第 474 行
merged["messages"] 同上 合并结果,最终进图的输入 第 475 行 + override

1. 提前补 8 个 Python 语法点(看懂下面代码的前提)

① dict.get(key, 默认值)

python 复制代码
state.get("messages", [])      # 取 state["messages"];如果没有这个键,返回 [],不报错

对比 state["messages"]:键不存在会抛 KeyError。.get 是"安全取值"。

② or [](空值兜底)

python 复制代码
state.get("messages", []) or []

or 在 Python 里返回"第一个为真的值"。所以:

  • 取到的值是 [m1, m2] → 真 → 用它
  • 取到的值是 None → 假 → 用右边的 []
  • 取到的值是 [] → 假(空列表算假)→ 用右边的 []

作用:把这个值保证成"一定是个列表"。

③ list(...)(复制一份)

python 复制代码
existing = list(state.get("messages", []) or [])

list(x) 创建一个新列表 ,元素和 x 一样。为什么要复制?因为后面如果对这个列表做 append、[i] = ...,不应该改到原始的 state。这是"防御性复制",不是必须,但是好习惯。

④ [*a, *b](列表解包 / 拼接)

python 复制代码
merged["messages"] = [*missing, *current]
  • *missing 意思是"把 missing 里的每个元素拆开铺在这里"
  • [*missing, *current] 等价于 missing + current,即"missing 的元素在前,current 的元素在后"

举例:missing=[1,2],current=[3] → 结果 [1, 2, 3]。

⑤ {x for x in ...}(集合推导式)

python 复制代码
current_ids = {getattr(m, "id", None) for m in current}
  • 读法:"对 current 里的每个 m,取 m.id,组成一个集合(set)"
  • 结果形如 {"h3"}(大括号,不是方括号)
  • 集合的特点 :查"某个值在不在里面"极快(x in 集合 是 O(1)),而列表要一个个比。所以代码里把 id 收集成 set 再判断。

等价于:

python 复制代码
current_ids = set()
for m in current:
    current_ids.add(m.id)

⑥ [m for m in ... if 条件](带过滤的列表推导式)

python 复制代码
missing = [m for m in existing if getattr(m, "id", None) not in current_ids]

读法:"对 existing 里每个 m,只有当 m.id not in current_ids 时才留下来"。

等价于:

python 复制代码
missing = []
for m in existing:
    if m.id not in current_ids:
        missing.append(m)

多条件用 and 连接,例如父类那段:

python 复制代码
[
    msg for msg in messages
    if msg.id not in existing_message_ids
    and not (isinstance(msg, ToolMessage) and ...)
]

读法:两个条件都满足才留下:

  1. msg.id 不在 checkpoint 的 id 集合里
  2. 且 not (...) ------ 括号里那坨为真时取反,即"不是(这种 tool 消息)"才留下

⑦ getattr(obj, "属性名", 默认值)

python 复制代码
getattr(m, "id", None)

意思是"取 m 的 id 属性;如果没有这个属性,返回 None,而不是报错"。

为什么不用 m.id?因为上游传进来的可能是普通 dict(没有 .id)。这行是"不信任对象形态"的防御写法。

⑧ **state(字典解包)与 isinstance

python 复制代码
return {**state, "messages": new_messages, "tools": unique_tools}
  • {**state} = 把 state 里所有键值对复制进来
  • 后面的 "messages": new_messages 如果 key 和前面重复,后面的覆盖前面的

所以这行的意思是:"以 state 为底,然后把 messages 和 tools 两个键替换掉"。

python 复制代码
isinstance(msg, ToolMessage)

判断"msg 是不是 ToolMessage 这一类(或它的子类)的对象"。返回 True/False。


说明:下面把两个版本合并起来------代码内部逐行加注释 ,同时原样保留打白活的解释与表格 。我没有修改任何文件(其中几段在 .venv 第三方库里)。


说明:下面每个代码块头部改成 起始行:结束行:文件相对路径 格式,在 IDE 里点一下就直接跳到对应文件对应行。行内注释仍是阅读辅助(不影响原文件)。


2. 第 1 步:从 HTTP 到 prepare_stream

121:123:src/agent/agui_endpoint.py 复制代码
@app.post(path)                                   # 装饰器:把下面的函数注册成 path(="/agui") 的 POST 路由
async def endpoint(
    input_data: RunAgentInput,                    # FastAPI 自动把请求 JSON 解析成这个对象(自带 .thread_id / .run_id / .messages / .tools)
    request: Request,                             # 原始 HTTP 请求(这里只为了读 Accept 头)
) -> StreamingResponse:                            # 返回类型:SSE 流式响应(不是一次性 JSON)
    return _RunResponse(
        agent,                                     # 已构建好的 SupervisorAGUIAgent 实例(图 + checkpointer 都挂在它身上)
        _Run(input_data),                          # 把这次请求包成"一次运行"的上下文
        EventEncoder(                              # 事件编码器:决定每个事件怎么序列化成 SSE 文本
            accept=request.headers.get("accept", "text/event-stream"),  # 客户端不传 Accept 就默认 SSE
        ),
    )
行 含义
L121 @app.post(path) = 注册一个 HTTP POST 接口(/agui)。装饰器语法,不用深究
L122 收到请求后,FastAPI 自动把 JSON body 解析成对象 input_data(类型 RunAgentInput),它自带 .thread_id、.run_id、.messages、.tools 等属性
L123 把 agent、这次请求、编码器打包成一个"流式响应对象"返回 ------ 真正的逻辑在这个类里异步跑

大白话:这里只是"收快递",真正的活在第 3 步。


3. 第 2 步:用 thread_id 取回上一轮的历史

203:206:.venv/Lib/site-packages/ag_ui_langgraph/agent.py 复制代码
# 位于 _handle_stream_events 内部,缩进 12 个空格

# self.config 是构建 agent 时传入的配置(我们传了 {"recursion_limit": 50})
# 有配置就浅拷贝一份,没有就用空字典;ensure_config 补全成 LangGraph 要求的标准结构
config = ensure_config(self.config.copy() if self.config else {})

# 把 thread_id 合进 config["configurable"]:
#   {**已有内容, "thread_id": thread_id} = 保留原有 configurable,再覆盖/添加 thread_id 键
config["configurable"] = {**(config.get('configurable', {})), "thread_id": thread_id}

# ★按 thread_id 从 checkpointer(Redis)读出这条会话上一次保存的完整 state
#   await = 等待异步调用完成
#   agent_state.values              → 那个 state 字典
#   agent_state.values["messages"]  → 上一轮的完整消息列表(本轮"接续"的物理来源)
agent_state = await self.graph.aget_state(config)
行 含义
L203 self.config 是构建 agent 时传的配置(我们传了 {"recursion_limit": 50})。self.config.copy() if self.config else {}:有就浅复制一份,没有就用空字典。ensure_config 把它补成 LangGraph 要求的标准格式
L204 往 config["configurable"] 里写入 thread_id 。{**(config.get('configurable', {})), "thread_id": thread_id} = "保留原有 configurable 的内容,然后把 thread_id 这个键覆盖/加上"
L206 await = 等待一个异步函数跑完。graph.aget_state(config) = 按 config 里的 thread_id,从 checkpointer(Redis)读出这条会话最后一次保存的 state 。返回对象有 .values 属性,.values 就是那个 state 字典

大白话:

  • thread_id 就是"会话 ID"。同一个 thread_id 的所有轮次,都共享 Redis 里同一份 state。
  • aget_state 就是"把上次的存档读出来"。
  • thread_id 换了 = 读不到存档 = 历史归零。 这是排查"第二轮失忆"的第一个检查点。

4. 第 3 步:把两份消息交给合并函数

468:475:.venv/Lib/site-packages/ag_ui_langgraph/agent.py 复制代码
state_input = input.state or {}          # 前端额外带的自定义 state(CopilotKit useCoAgent 用),没有就给 {}
messages = input.messages or []          # ★前端发来的消息列表(箱子 B),通常含全量历史 + 本轮新问题
forwarded_props = input.forwarded_props or {}   # 透传属性(node_name / command / injectA2UITool 等)
thread_id = input.thread_id              # 会话 ID

# ★把 Redis 里的历史塞进 state_input 的 messages 键(箱子 A)
#   注意:没有 copy ------ 此刻 state_input["messages"] 与 agent_state.values["messages"] 是同一个列表对象
state_input["messages"] = agent_state.values.get("messages", [])

# ★把 AG-UI 协议消息(字典风格)转成 LangChain 消息对象(HumanMessage / AIMessage / ToolMessage)
#   转换会保留 id ------ 后面父类 / override / reducer 全靠 id 对齐,id 丢了就出问题
langchain_messages = agui_messages_to_langchain(messages)

# ★调合并函数,得到最终 state
#   self.xxx = 调本对象的方法;因为 SupervisorAGUIAgent 重写了同名方法,实际执行子类版本(见第 6 节)
state = self.langgraph_default_merge_state(state_input, langchain_messages, input)
行 含义
L468 input.state 是前端额外带的状态(CopilotKit 的 useCoAgent state),没有就是 {}
L469 messages = 前端发来的消息列表(箱子 B)
L473 把 Redis 里的历史,写进 state_input["messages"] (箱子 A)。注意此时 state_input["messages"] 和 agent_state.values["messages"] 是同一个列表对象(没复制)
L474 agui_messages_to_langchain(...):把 AG-UI 协议的消息对象(字典风格)转成 LangChain 的 HumanMessage / AIMessage / ToolMessage 对象。转换时会保留 id,这一步很重要,后面全靠 id 对齐
L475 调合并函数,得到最终 state。self.langgraph_default_merge_state 是方法调用 (self. 表示调这个对象自己的方法);因为我们的 SupervisorAGUIAgent 重写了同名方法,所以实际执行的是我们的版本(见第 6 步)

大白话:代码到这一行时,两个箱子都准备好了:

  • state_input["messages"] = 服务端历史(比如 6 条)
  • langchain_messages = 前端消息(比如 5 条,含本轮新问题)

接下来要合并。


5. 第 4 步:父类合并算法(历史在这里被丢掉)

这是整条链路最核心、最需要看懂的一段。方法在 ag_ui_langgraph 库里,self.langgraph_default_merge_state(...) 调用的就是它。

5.1 去掉开头的 SystemMessage

756:758:.venv/Lib/site-packages/ag_ui_langgraph/agent.py 复制代码
def langgraph_default_merge_state(
    self,
    state: State,                    # 含服务端历史的 state 字典("messages" 键 = Redis 存档)
    messages: List[BaseMessage],     # 前端消息转成的 LangChain 对象列表
    input: RunAgentInput,            # 原始请求(这里用来取 tools / context)
) -> State:                          # 返回值是一个 state 字典
    # 若前端把 system prompt 也发上来了,就丢掉第一条
    #   messages and ...                        → 先保证列表非空,避免 messages[0] 越界
    #   isinstance(messages[0], SystemMessage)  → 判断第一条是不是 SystemMessage
    if messages and isinstance(messages[0], SystemMessage):
        messages = messages[1:]      # 切片:从第 2 个元素取到末尾(丢掉第 1 个)
行 含义
L756 方法定义。三个参数:state(含历史的消息箱)、messages(前端转好的消息)、input(原始请求)。返回类型 State(一个字典)
L757 两个条件同时满足:messages 非空 且 第一条是 SystemMessage。messages[0] 取第一个元素
L758 messages[1:] = 切片 ,从第 2 个元素到末尾(丢掉第 1 个)。赋回 messages 变量

为什么 :前端有时会把 system prompt 一起发上来,但服务端的 system 由模板提供,不能重复。

我们项目:CopilotKit 一般不传 SystemMessage,通常不触发。

5.2 取出服务端历史

764:764:.venv/Lib/site-packages/ag_ui_langgraph/agent.py 复制代码
# 取服务端历史并复制一份(list(...) = 浅拷贝出新列表)
# 后续会对它做 existing_messages[idx] = 替换,复制是为了不改到原始 state 里的列表
# : List[BaseMessage] 只是类型注解,运行时不检查(给人读 / 给 mypy 检查)
existing_messages: List[BaseMessage] = list(state.get("messages", []))
部分 含义
list(...) 复制一份
state.get("messages", []) 取历史;没有就空列表
: List[BaseMessage] 类型注解(Python 自己不检查,只是给人看 / 给 mypy 检查)

大白话 :existing_messages 就是"上一轮存下来的完整历史"。这一刻它是 6 条。

5.3 修复 tool_call 参数(对我们基本不触发)

770:781:.venv/Lib/site-packages/ag_ui_langgraph/agent.py 复制代码
repaired_ai_messages: List[AIMessage] = []          # 装"修复过的 AI 消息"(我们场景恒为空)

for idx, msg in enumerate(existing_messages):      # enumerate → 同时拿下标 idx(0 起)和元素 msg
    # Only AIMessages with tool_calls can need repair. Skip the rest
    # cheaply so we don't deepcopy every checkpoint message just to
    # discover there was nothing to fix.
    # 条件 A:是 AIMessage;条件 B:tool_calls 存在且非空
    # not (A and B) → 只要有一个不满足就跳过(continue = 结束本次循环,进入下一个元素)
    if not (isinstance(msg, AIMessage) and getattr(msg, 'tool_calls', None)):
        continue

    # any(...) = 只要有一个元素满足条件就 True
    # 条件:某个 tool_call 的 args 是 str(正常应为 dict)→ 数据坏了,需要修
    if not any(isinstance(tc.get('args'), str) for tc in msg.tool_calls):
        continue

    msg = deepcopy(msg)              # 深拷贝,避免改到 checkpoint 里的原对象
    existing_messages[idx] = msg     # 按下标把修复后的副本写回列表
行 含义
L770 准备一个空列表,装"修复过的 AI 消息"
L771 enumerate(list) 同时给出 下标 (idx,从 0 开始)和 元素 (msg)
L775 if not (A and B): continue。continue = 跳过本次循环,进入下一个元素 。A=是 AIMessage,B=有 tool_calls 且非空。两者都满足才继续往下
L777 any(... for tc in msg.tool_calls) = "只要有一个 tool_call 满足条件就 True"。条件:args 是字符串(正常应该是 dict)。是字符串就说明数据坏了,需要修
L780 deepcopy(msg) 深拷贝一份(避免改到原始对象)
L781 existing_messages[idx] = msg = 按下标替换成修复后的副本

为什么存在 :某些上游(Bedrock/CopilotKit 前端工具回传)会把 tool_calls[].args 存成 JSON 字符串,模型 API 要求是对象,所以要转回来。

我们项目 :workflow 模式我们自己的卡片 ToolMessage 是 a2ui-xxx 且不带 tool_calls,这段基本是空转(每次循环都在 L775 continue 掉)。看代码时可以略过,但它解释了一件事:repaired_ai_messages 一般是空列表。

5.4 取前端 ToolMessage 的内容,修"孤儿 tool 消息"

814:819:.venv/Lib/site-packages/ag_ui_langgraph/agent.py 复制代码
# 字典推导式:{键: 值 for 元素 in 列表 if 条件}
# 目的:把前端带来的 ToolMessage 变成 "tool_call_id → 内容" 的字典,方便后面按 id 查真实内容
agui_tool_content = {
    m.tool_call_id: m.content
    for m in messages
    if isinstance(m, ToolMessage) and hasattr(m, 'tool_call_id')
}
replaced_tool_call_ids = set()                        # 记录哪些 tool_call_id 已被替换(防重复添加)
repaired_tool_messages: List[ToolMessage] = []        # 装修复后的 tool 消息(我们场景恒为空)
行 含义
L814-818 字典推导式 :{键: 值 for 元素 in 列表 if 条件}。把前端发来的 ToolMessage 转成"tool_call_id → 消息内容"的字典,方便后面按 id 查内容
L819 空集合,记录"哪些 tool_call_id 已经被替换过"
L820 空列表,装修复后的 tool 消息
821:841:.venv/Lib/site-packages/ag_ui_langgraph/agent.py 复制代码
if agui_tool_content:                                 # 前端确实带了 ToolMessage 才处理
    last_human_idx = -1                               # -1 表示"还没找到"
    # range(起点, 终点(不含), 步长):从最后一个下标倒着数到 0
    for i in range(len(existing_messages) - 1, -1, -1):
        if isinstance(existing_messages[i], HumanMessage):
            last_human_idx = i                        # 记住"最后一条 user 消息"的下标
            break                                     # 找到就跳出循环
    if last_human_idx >= 0:                           # 找到了才继续
        # 遍历"最后一条 human 之后"的所有消息 ------ 只修本轮,不动历史段
        for i in range(last_human_idx + 1, len(existing_messages)):
            msg = existing_messages[i]
            if (
                    isinstance(msg, ToolMessage)                          # ① 是 tool 消息
                    and isinstance(msg.content, str)                      # ② 内容是字符串
                    and self._ORPHAN_TOOL_MSG_RE.match(msg.content)       # ③ 是"工具调用被中断"的固定句式
                    and hasattr(msg, 'tool_call_id')                      # ④ 有 tool_call_id
                    and msg.tool_call_id in agui_tool_content             # ⑤ 前端确实带了这个 id 的真实内容
            ):
                msg = deepcopy(msg)                                       # 深拷贝
                msg.content = agui_tool_content[msg.tool_call_id]          # 用前端真实内容替换假内容
                existing_messages[i] = msg                                # 写回列表
                repaired_tool_messages.append(msg)                        # 记进修复列表
                replaced_tool_call_ids.add(msg.tool_call_id)              # 记进"已替换"集合,后面过滤要用
行 含义
L821 只有前端确实带了 ToolMessage 才进这段
L822 -1 表示"还没找到"
L823 range(len-1, -1, -1) = 从最后一个下标倒着 遍历到 0(range(起点, 终点(不含), 步长))
L824-826 找到最后一条 HumanMessage,记下它的下标,break 立刻跳出循环
L828 从"最后一条 human 之后"一直遍历到末尾 ------ 只修本轮的 tool 消息,不动历史的
L830-836 5 个条件全满足才修:是 ToolMessage、内容是字符串、内容匹配"工具调用被中断"的固定句式(_ORPHAN_TOOL_MSG_RE)、有 tool_call_id、且前端确实带了这个 id 的内容
L837-841 深拷贝 → 用前端内容替换假内容 → 写回列表 → 记进 repaired 列表和 replaced 集合

为什么存在:当一轮请求被中断,LangGraph 会插入一条假的 tool 消息("Tool call 'x' was interrupted...")。下次前端把真实结果发回来时,要把假的替换掉。

5.5 核心:构造 new_messages(问题就在这)

843:857:.venv/Lib/site-packages/ag_ui_langgraph/agent.py 复制代码
# 把历史里所有消息的 id 收成集合(集合查找 O(1),比列表快)
# ★注意:只取了 id,历史消息的"内容"一次都没被放进来
existing_message_ids = {msg.id for msg in existing_messages}

new_messages = [
    *repaired_ai_messages,      # 第 1 段:* = 拆开铺平(我们场景为空)
    *repaired_tool_messages,    # 第 2 段:同上(我们场景为空)
    *[                          # 第 3 段:★从"前端消息"里过滤,历史内容不在这里
        msg for msg in messages                        # 遍历前端发来的每条消息
        if msg.id not in existing_message_ids          # 条件①:id 不在历史里(即"这是新消息")
        and not (                                      # 条件②:且不属于"已被替换过的孤儿 tool 消息"
            isinstance(msg, ToolMessage)               #   ------是 tool 消息
            and hasattr(msg, 'tool_call_id')           #   ------有 tool_call_id
            and msg.tool_call_id in replaced_tool_call_ids  # ------且该 id 刚在 5.4 被替换过
        )                                              # (该 id 已通过 repaired_tool_messages 补过,避免重复)
    ],
]

L843 :把历史里所有消息的 id 收集成集合,形如 {"h1","a1","t1","h2","a2","t2"}。注意:只取 id,消息本身没进结果。

L845-857 这个复杂结构拆开看,外层是一个三段的列表(示意,非文件原文):

python 复制代码
new_messages = [
    *repaired_ai_messages,      # 第 1 段:修复过的 AI 消息(通常空)
    *repaired_tool_messages,    # 第 2 段:修复过的 tool 消息(通常空)
    *[ ... ],                   # 第 3 段:过滤后的前端消息
]

第 3 段逐字读:

片段 含义
msg for msg in messages 遍历前端发来的每一条消息
if msg.id not in existing_message_ids 条件 1:这条消息的 id 不在服务端历史里(即"这是新消息")
and not ( ... ) 条件 2:且不属于"已被替换过的孤儿 tool 消息"(避免重复添加)
isinstance(msg, ToolMessage) and hasattr(...) and msg.tool_call_id in replaced_tool_call_ids 括号里:是 ToolMessage、有 tool_call_id、且该 id 已在 L841 被替换过

代入我们的真实数据(前端发了全量历史 + 本轮新问题):

前端消息 id 在 existing_message_ids 里? 条件 1 结果 是否进 new_messages
h1 h1 在 False ❌
a1 a1 在 False ❌
h2 h2 在 False ❌
a2 a2 在 False ❌
h3(本轮新问题) h3 不在 True ✅

结果:new_messages = [h3],只有 1 条。

为什么历史会被丢?因为 existing_messages(6 条历史)从头到尾只贡献了 id 用于过滤(L843),它的内容一次都没被放进 new_messages。

5.6 返回:把 messages 覆盖掉

919:928:.venv/Lib/site-packages/ag_ui_langgraph/agent.py 复制代码
return {
    **state,                          # 以传入的 state 为底(展开所有键值对)
    "messages": new_messages,         # ★覆盖 messages ------ 此时只有 [h3],历史被丢弃
    "tools": unique_tools,            # 前端工具列表(按名字去重后)
    "ag-ui": ag_ui_state,             # A2UI schema / context 等 AG-UI 专用字段
    "copilotkit": {                   # copilotkit 字段:保留原有内容
        **state.get("copilotkit", {}),#   先展开已有的(没有就给 {})
        "actions": unique_tools,      #   只覆盖 actions
    },
}
部分 含义
{**state, ...} 以原 state 为底
"messages": new_messages 覆盖 messages 键为 [h3]
"tools": unique_tools 前端工具列表
"ag-ui": ag_ui_state A2UI schema 等
"copilotkit": {**state.get("copilotkit", {}), "actions": ...} 保留原有 copilotkit 字段,只覆盖 actions

结论 :父类返回的 state 里 messages 只有 1 条。如果就这样进图,历史就丢了。这就是我们要 override 的原因。


6. 第 5 步:我们的 override(把历史补回来)

6.1 什么是 override(重写父类方法)

python 复制代码
# 示意,非文件原文
class SupervisorAGUIAgent(LangGraphAGUIAgent):       # 括号里是父类:本类继承 LangGraphAGUIAgent
    def langgraph_default_merge_state(self, ...):    # 与父类同名的方法 = 重写(override)
        # 外部调用 agent.langgraph_default_merge_state(...) 时,执行的是这里的版本
        ...

Python 里,子类定义与父类同名 的方法,就覆盖掉了父类的。外部调 agent.langgraph_default_merge_state(...) 时执行子类的版本。想用父类逻辑就显式写 super().方法名(...)。

6.2 完整带注释版

396:414:src/agent/agui_agent.py 复制代码
def langgraph_default_merge_state(self, state: dict, messages: list, input: object) -> dict:
    """Override:修 adapter 丢 checkpoint 历史 messages 的 bug。

    ag_ui_langgraph 的 langgraph_default_merge_state 构造 new_messages 时
    只含 repaired + input 新消息,漏了 existing_messages(checkpoint 历史),
    导致多轮对话 messages 只剩本轮 input 的1条。这里在父类合并后把历史
    messages 补回(去重,保留 input 新消息)。
    """
    # ① 先让父类按它原来的逻辑算一遍(这一步会丢掉历史)
    #    super() = 父类对象;super().方法(...) = 调父类的同一个方法
    merged = super().langgraph_default_merge_state(state, messages, input)

    # ② 从"传进来的 state"取服务端历史
    #    state 就是第 4 节的 state_input,L473 已把 Redis 存档塞进它的 "messages" 键
    #    .get(..., []) → 键不存在给空列表;or [] → 值为 None 也转成空列表;list(...) → 复制
    existing = list(state.get("messages", []) or [])

    # ③ 从"父类结果"取消息(通常只有本轮新消息)
    current = list(merged.get("messages", []) or [])

    # ④ 第一轮没有历史 → 没什么可补,直接返回父类结果
    #    not [] 为 True,not [1,2] 为 False
    if not existing:
        return merged

    # ⑤ 把 current 里已有的 id 收成集合,用于去重判断
    #    getattr(m, "id", None) → 取 m.id,属性不存在时给 None(不抛异常)
    current_ids = {getattr(m, "id", None) for m in current}

    # 历史中不在 current 的,补到 current 前面(保持时序)
    # 列表推导:遍历 existing,只保留"id 不在 current_ids 里"的(= 被父类丢掉的那些)
    missing = [m for m in existing if getattr(m, "id", None) not in current_ids]

    # ⑥ 有缺口才合并:历史在前(更早),本轮新消息在后(最新)→ 时序正确
    #    [*missing, *current] 等价于 missing + current
    if missing:
        merged["messages"] = [*missing, *current]  # type: ignore[assignment]  ← 让类型检查器忽略该赋值

    # ⑦ 返回修好的 state
    return merged

6.3 逐行

行 代码 含义 代入值
L404 super().langgraph_default_merge_state(state, messages, input) super() = 父类。这一句是"先让父类按它原来的逻辑算一遍" 返回 {... "messages": [h3]}
L405 existing = list(state.get("messages", []) or []) 从传进来的 state 取服务端历史(这个 state 就是 state_input,L473 已经把 Redis 历史塞进它的 messages) [h1,a1,t1,h2,a2,t2]
L406 current = list(merged.get("messages", []) or []) 从父类结果取消息 [h3]
L407-408 if not existing: return merged 如果服务端没历史(第一轮),没必要补,直接返回。not [] 是 True 不触发
L409 current_ids = {getattr(m,"id",None) for m in current} 父类结果里已有的 id 集合 {"h3"}
L411 missing = [m for m in existing if m.id not in current_ids] 历史里那些没出现在父类结果中的消息 [h1,a1,t1,h2,a2,t2](6 条全没出现)
L412-413 merged["messages"] = [*missing, *current] 历史在前、父类结果在后,写回 merged [h1,a1,t1,h2,a2,t2,h3]
L414 return merged 返回 7 条

6.4 为什么这样写就"对"了

  • 顺序 :[*missing, *current] ------ missing 是更早的历史,current 是本轮新消息,所以历史在前、新消息在后,时序正确。
  • 不重复 :missing 的筛选条件是"id 不在 current 里",所以两边不会出现同一条。
  • 零成本 :父类已经算过的修复(AI args / 孤儿 tool)都在 current 里保留着,我们只是把被丢掉的 existing 补回去。

6.5 这段代码的两个隐含前提(debug 时要盯)

前提 不满足会怎样
消息都有 id 若某条历史 id=None,current 里也有 None 时,missing 会把该历史误判为"已存在"而不补回
前端重发历史时 id 不变 若客户端重建了 id (同内容不同 id),父类会把它们当新消息全收(current 变大),override 又会把 checkpoint 里的旧版全补到前面 → 历史出现两份

7. 第 6 步:进图时的 add_messages reducer(为什么不会重复)

189:193:src/models.py 复制代码
# CopilotKit / AG-UI: langchain messages(CopilotChat 渲染对话用)。
# 非 CopilotKit 场景(纯 A2A / 测试)可为空;ingest 节点从中提取 query,
# respond 节点往里追加 AIMessage。
#
# Annotated[类型, 合并函数] = "类型是 list[BaseMessage],但每次更新时
# 不覆盖,而是调用 add_messages(旧值, 新值) 合并"
# 所以节点 return {"messages": [...]} 永远不会整体替换 messages
messages: Annotated[list[BaseMessage], add_messages]

Annotated[X, f] = "类型是 X,但更新时用函数 f 来合并"。所以每次节点返回 {"messages": [...]},都不是"替换",而是调用 add_messages(旧值, 新值)。

add_messages 的实现(LangGraph 库):

202:208:.venv/Lib/site-packages/langgraph/graph/message.py 复制代码
# assign missing ids ------ 给没有 id 的消息现场补一个 uuid
# (注意:这是直接改对象属性 m.id = ...,会改动原对象)

for m in left:                       # left = checkpoint 里的旧消息列表
    if m.id is None:                 # 没有 id
        m.id = str(uuid.uuid4())     # 生成全新 uuid(随机,永远不重复)
for idx, m in enumerate(right):      # right = 本次输入的新消息列表
    if m.id is None:
        m.id = str(uuid.uuid4())     # 同样补 uuid → 永远命中不了旧消息 → 后面只能 append
215:234:.venv/Lib/site-packages/langgraph/graph/message.py 复制代码
# merge ------ 真正的合并逻辑
merged = left.copy()                 # 以旧列表为基底复制(不回头改 left)
merged_by_id = {m.id: i for i, m in enumerate(merged)}
                                     # 字典推导:{消息 id: 它在列表里的下标},O(1) 定位
ids_to_remove = set()                # 待删除的 id 集合(RemoveMessage 语义,我们不用)
for m in right:                      # 遍历本次输入的消息
    # 海象运算符 := :先算右边、赋值给 existing_idx、再用它做判断
    # merged_by_id.get(...) 没有该 id 时返回 None
    if (existing_idx := merged_by_id.get(m.id)) is not None:
        if isinstance(m, RemoveMessage):      # 是删除指令 → 记入待删集合
            ids_to_remove.add(m.id)
        else:
            ids_to_remove.discard(m.id)       # 若之前标记过删除,撤销
            merged[existing_idx] = m          # ★id 命中 → 原地替换,位置不变
    else:
        if isinstance(m, RemoveMessage):      # 要删一个不存在的 id → 直接报错(Fail-Fast)
            raise ValueError(
                f"Attempting to delete a message with an ID that doesn't exist ('{m.id}')"
            )

        merged_by_id[m.id] = len(merged)      # 新 id 登记到索引表(下标 = 当前长度)
        merged.append(m)                      # ★id 未命中 → 追加到末尾
merged = [m for m in merged if m.id not in ids_to_remove]   # 统一执行删除

逐行:

行 含义
L202-205 left(checkpoint 里的旧消息)如果 id 是 None,就现场生成一个 uuid 赋给它 (注意:这是直接改对象属性)
L206-208 right(本次输入的消息)同理,id 为 None 也生成 uuid
L216 复制 left 作为合并基底
L217 建索引:{id: 在列表中的下标}。{m.id: i for i, m in enumerate(merged)} 是字典推导式
L219 遍历 right 的每条消息
L220 (existing_idx := ...) 是海象运算符:先算右边、赋值给 existing_idx、再用这个值做判断。判断"这个 id 在 merged 里有没有"
L225 有 → 原地替换 (merged[下标] = m),位置不变
L232-233 没有 → 追加到末尾
L234 处理 RemoveMessage(删除语义),我们不用

代入我们的场景(示意):

python 复制代码
left  = [h1, a1, t1, h2, a2, t2]        # checkpoint 旧值
right = [h1, a1, t1, h2, a2, t2, h3]    # 本次输入(override 合并后的 7 条)
# h1...t2:id 全部命中 → 依次原位替换(内容相同,等于没变)
# h3    :id 未命中 → append
# 结果  :[h1, a1, t1, h2, a2, t2, h3]   ← 7 条,无重复

关键点 :add_messages 对没有 id 的消息会自动发一个新 uuid (L206-208),所以无 id 消息永远命中不了旧消息 → 永远走 append。这就是"id 缺失 = 重复"的机制。


8. 第 7 步:ingest 给本轮消息打时间戳(为什么必须是 model_copy)

333:348:src/graph.py 复制代码
msgs = state.get("messages", [])          # 取整个消息列表(此时已含历史 + 本轮)
if msgs:                                  # 非空才处理(空列表本身为假)
    last = msgs[-1]                       # 负下标:-1 = 最后一个元素(本轮 HumanMessage)
    # query 提取防污染:content 可为 block list(含 image block),拼接 text block
    from src.common.image_inputs import extract_text_query
    update["query"] = extract_text_query([last])   # 从最后一条里抽纯文本,作为本轮 query

    # 给本轮入站消息打 created_at(ms):多轮结构化历史 item 需要时间信息
    # (LLM messages 缺时间)。同 id 覆盖、不新增消息;打点失败不阻断 ingest。
    try:
        if getattr(last, "type", "") == "human":        # 只给用户消息打点
                                                       # type: "human"/"ai"/"tool"
            ak = dict(getattr(last, "additional_kwargs", {}) or {})
                                                       # 复制附加字段字典(避免改原对象)
            if "created_at" not in ak:                  # 已打过就不重复打
                ak["created_at"] = int(time.time() * 1000)     # 毫秒时间戳
                update["messages"] = [
                    last.model_copy(update={"additional_kwargs": ak})
                    # model_copy = Pydantic 的"复制对象 + 覆盖指定字段"
                    # ★副本保留原 id → 走 add_messages 时同 id 命中 → 原位替换,不新增
                ]
    except Exception as exc:  # noqa: BLE001 --- 打点失败不阻断 ingest
        logger.debug("ingest: stamp created_at failed: %s", exc)
行 含义
L333 取消息列表
L334 if msgs: 非空才继续(空列表算 False)
L335 msgs[-1] = 最后一个元素(负下标从尾部数,-1 是最后一个)
L337-338 从最后一条消息里抽纯文本,作为本轮 query
L342 getattr(last, "type", "") == "human" ------ LangChain 消息的 type 属性:HumanMessage 是 "human",AI 是 "ai",Tool 是 "tool"
L343 dict(...) 复制一份 additional_kwargs(附加字段字典),避免改到原对象
L344 已经有 created_at 就不重复打
L346 model_copy(update={...}) = Pydantic 的"复制一个对象,并用 update 里的字段覆盖"。复制会保留 id
L346 把它包成列表放进 update["messages"],返回给框架 → 走 reducer → 同 id → 原位替换,不新增消息

如果入站消息没有 id :model_copy 出来的副本也没有 id → add_messages 给它发一个新 uuid → 追加而不是替换 → 本轮 query 在 messages 里出现两条。

这就是"历史里出现两条相同问题"的根因,也是 debug 时第一个要看的字段。


9. 全流程变量快照(第 3 轮为例)

时点 变量 值(条数) 可点击位置
请求到达 input.messages [h1,a1,h2,a2,h3](5) src/agent/agui_endpoint.py:122
L206 agent_state.values["messages"] [h1,a1,t1,h2,a2,t2](6,来自 Redis) .venv/Lib/site-packages/ag_ui_langgraph/agent.py:206
L473 state_input["messages"] 同上(6,同一对象) .venv/Lib/site-packages/ag_ui_langgraph/agent.py:473
L474 langchain_messages [h1,a1,h2,a2,h3](5,对象化后) .venv/Lib/site-packages/ag_ui_langgraph/agent.py:474
父类 L764 existing_messages 6 .venv/Lib/site-packages/ag_ui_langgraph/agent.py:764
父类 L843 existing_message_ids {h1,a1,t1,h2,a2,t2} .venv/Lib/site-packages/ag_ui_langgraph/agent.py:843
父类返回 merged["messages"] [h3](1,历史已丢) .venv/Lib/site-packages/ag_ui_langgraph/agent.py:919
override L405 existing 6 src/agent/agui_agent.py:405
override L406 current 1 src/agent/agui_agent.py:406
override L411 missing 6 src/agent/agui_agent.py:411
override L413 merged["messages"] [h1,a1,t1,h2,a2,t2,h3](7) src/agent/agui_agent.py:413
进图 add_messages(left=6, right=7) 7(6 替换 + 1 追加) .venv/Lib/site-packages/langgraph/graph/message.py:216
ingest msgs / last 7 / h3 src/graph.py:333
intent 节点 build_history_messages 的 msgs[:-1] 6(去掉 h3) src/common/history_messages.py:156
respond 之后 Redis messages 9(+ 本轮 AI + 卡片 Tool) src/graph.py:2043

10. 语法速查表(回看代码时对照)

写法 读法 例子 出现位置
x.get(k, d) 安全取键 state.get("messages", []) agent.py:764、graph.py:333
x or y x 为假就用 y state.get("messages") or [] agui_agent.py:405
list(x) 复制成新列表 list(state.get(...)) agui_agent.py:405
[*a, *b] 拼接 a 和 b [*missing, *current] agui_agent.py:413
a[1:] 从第 2 个到末尾 messages[1:] agent.py:758
a[-1] 最后一个 msgs[-1] graph.py:335
{x for x in xs} 集合推导 {m.id for m in current} agui_agent.py:409
{k: v for x in xs} 字典推导 {m.tool_call_id: m.content ...} agent.py:814
[x for x in xs if 条件] 过滤列表 [m for m in existing if ...] agui_agent.py:411
getattr(o, "k", None) 取属性,缺了给默认 getattr(m, "id", None) agui_agent.py:409
isinstance(x, T) 是不是 T 类型 isinstance(msg, ToolMessage) agent.py:833
**d 字典解包 {**state, "messages": ...} agent.py:919
x := ... 海象:边赋值边判断 if (i := d.get(k)) is not None: message.py:220
for i, x in enumerate(xs) 同时拿下标和元素 for idx, msg in enumerate(...) agent.py:771
range(n-1, -1, -1) 倒序数字序列 从后往前遍历 agent.py:823
super().m(...) 调父类的同名方法 super().langgraph_default_merge_state(...) agui_agent.py:404
continue / break 跳过本次 / 跳出循环 L775 / L826 agent.py:775、826
not x 取反(空列表/None/0 都是真) if not existing: agui_agent.py:407

11. 一句话总回顾

python 复制代码
# 数据流向(5 个数字必须能串上)
input.messages            = 5 条   # 前端发的,通常含全量历史 + 本轮新问题
agent_state.messages      = 6 条   # Redis 存档(上一轮结束时)
        ↓  父类 langgraph_default_merge_state
merged.messages           = 1 条   # ✗ 只留 id 不在历史里的 h3 → 历史被丢
        ↓  我们的 override(把 missing 补回前面)
merged.messages           = 7 条   # [h1,a1,t1,h2,a2,t2,h3]
        ↓  add_messages(按 id 对齐:6 替换 + 1 追加)
state.messages            = 7 条   # ingest 取 [-1] 当 query,intent/planner 用 [:-1] 当历史

要 debug 这段,只需要盯一件事:每一步的 len(messages) 和 id 列表。这五个数字(5 → 6 → 1 → 7 → 7)只要有一个对不上,问题就定位在那一行。

用一个背景讲:医院复诊,档案柜 vs 患者自带的本子

假设你是病人,每来一次就是一轮对话。

  • 病历号 = thread_id:挂号时报的病历号。号对了,护士才能去档案柜找到你的老病历。
  • 档案柜 = Redis(服务端存档) :医院留的正式病历,权威。
  • 患者手里的本子 = 前端发来的 messages:你自己抄的一份,通常把前几次全抄了一遍------因为客户端默认每轮都发全量。

第 1 步:护士去档案柜取老病历(对应 6 条)

你报病历号 → 护士打开柜子 → 抽出你那 6 页老病历。如果报错号,柜子里没有你的档案,直接当新病人处理(历史归零)。

第 2 步:护士对比"你的本子"和"正式病历"(对应父类合并)

问题是------这个护士的做法有点蠢。她的规则是:

"你的本子我看了,前几页档案里都有,不用管。只有档案里没有的那页才算新的,我抄下来送进诊室。"

于是:

你本子上写的 档案里有没有 护士抄不抄
第 1 页(上次问的) 有 不抄
第 2 页(上次答的) 有 不抄
第 3 页(上次的卡片) 有 不抄
...... 有 不抄
最后一页(你今天新问的) 没有 ✅ 抄

结果她只拿 1 页进诊室,然后把档案合上放回柜子了。

→ 医生只看得到你今天这一句话,完全不知道你上周说过什么。 这就是「历史被丢」------不是被删了,是没被带进诊室。

第 3 步:我们的补丁(对应 override)

我们在门口加了个细心的人。她在护士送诊之前看一眼:

"档案里明明有 6 页,你只带了 1 页进去?把没被带进去的那 6 页按原顺序补到这 1 页前面。"

→ 进诊室时手里是 7 页:老病历 6 页 + 你今天这 1 页。顺序也对(老的在前、新的在后)。

第 4 步:医生看完,写新页归回档案柜(对应 add_messages)

医生在最后一页写了诊断(本轮回复 + 卡片),要放回档案柜。归档员的规则只有一个:

每页都有页码(id)。

  • 页码在档案里已有 → 用新版本替换那一页,位置不动(防止重复)
  • 页码是新的 → 追加到最后一页

所以 7 页归档后还是 7 页(6 页是"原样换一遍",1 页是新增),下一轮柜子里就是 7 页 + 医生新写的 → 9 页。

页码为什么是命根子 :如果某页没有页码 ,归档员会临时给它编一个新号------那它永远对不上旧页,只能当"新的一页"追加 → 同一句话出现两份。这就是「id 缺失 = 历史重复」的真相。


把 5 个数字翻译成人话

数字 场景语言
5 条 你本子上抄了 5 页(4 页旧的 + 1 页新的)
6 条 档案柜里存着 6 页(上一轮结束时医院留的)
1 条 护士按"只抄档案里没有的"规则,只抄出 1 页送进诊室 ← bug 现场
7 条 补丁把档案里漏掉的 6 页补回前面 → 6 + 1 = 7 页进诊室
7 条 归档员按页码对齐:6 页原位替换 + 1 页追加 → 还是 7 页,不重复

一句话记住

两份记录合并时:先按"新不新"筛一遍(只剩 1 条,历史丢了),再按"缺不缺"补回来(回到 7 条),最后归档按"页码"对齐(不重复)。

所以你 debug 时不用看代码逻辑,只要在三道关口各数一下手上有几页、页码分别是什么 :

诊室门口是不是 7 页? 不是 → 补丁没生效;归档后是不是还是 7 页? 变多了 → 有页没页码。

用病历例子,看第 5、6、7 步各干了什么

先把三个角色的分工说清楚:

章节 角色 一句话职责
第 5 步 护士 A(医院原流程) 只看"你本子上哪些是新的",抄出来送诊 → 只带 1 页进去
第 6 步 门口补页的人(我们加的补丁) 发现档案漏了,把漏的 6 页补到前面 → 7 页进诊室
第 7 步 归档员(LangGraph 的 reducer) 医生写完后按页码 把新页归回档案 → 还是 7 页,不重复

顺序上:5 先筛 → 6 再补 → 7 最后归档对齐,缺一道都不行。


第 5 步:护士 A 的六个小动作

5.1 撕掉你本子第一页的"医院规矩"

如果你自己在本子开头抄了一遍《就诊须知》(system prompt),护士先撕掉------规矩由医院统一发,不看病人抄的版本 。

(我们这里 CopilotKit 一般不会抄,所以基本不触发。)

5.2 从柜子里抽出老病历

按病历号抽出 6 页,并且复印一份 再用------原因是后面可能要在纸上修改,不能涂改医院存档的原件。

(list(...) 复制,就是"别在原件上写"。)

5.3 检查老病历里有没有"写坏的表格"

有些病历里的"处方表格"本该是填格子,却被上游写成一长串文字。护士会把它重新誊清再放进档案。

我们科室没有这种表格(我们的卡片是另一种格式,不带工具调用),所以这一步每次都空转跳过。

5.4 把"上次没看完盖的假章页"换成真页

如果某次你看病看到一半走了(请求被中断),医院会在病历上盖一页**"本次未完成"的假章**。下次你把当时真实的检查结果带来,要把这页假章换成真内容。

规矩是:只换"最后一次复诊之后"的页,绝不动更早的老病史。

5.5 核心:只抄"新页",档案本身不带进去 ⚠️

护士 A 从档案里抽出页码清单当对照表,然后对着你的本子挨页看:

"这一页档案里有 → 不抄;这一页也有 → 不抄......只有这页(你今天新问的)档案里没有 → 抄它。"

于是送进诊室的是 1 页 。

注意关键点:她只是拿档案核对了一遍页码,档案本身塞回柜子了,一页都没带走。

5.6 交付时把"整本 messages"换成那 1 页

最后她把要送诊的资料打包,messages 那一栏写的是这 1 页 ------ 不是追加,是替换掉原来该放档案的位置 。

到这一步,"历史丢了"正式落地。


第 6 步:门口补页的人(override)

6.1 为什么能插手

医院换了个新护士(子类),岗位名称和旧护士一模一样(同名方法),但做事方式改成"我先按旧流程跑一遍,再自己补一手"。所以她在流程上仍然存在,只是多做了事。

6.2 她实际做了七个动作

  1. 先让护士 A 按老规矩跑一遍 → 拿到那 1 页。
  2. 把档案抽出来(6 页)。
  3. 把护士 A 送诊的那 1 页也拿出来。
  4. 如果档案本来是空的(第一次来的新病人)→ 没什么可补,直接放行。
  5. 记下这 1 页的页码("哪些页已经在了")。
  6. 从 6 页档案里,挑出页码不在其中的那些 → 6 页全被挑中(因为护士 A 一页都没带)。
  7. 把这 6 页按原顺序摆到那 1 页前面 → 7 页。

6.3 为什么这么补是对的

  • 顺序对:老病历在前、今天新问的在后,医生读起来是正常时间线。
  • 不重复:挑的条件就是"页码不在这 1 页里",两边天然不重叠。
  • 不白干:护士 A 前面做的誊清、换假章页都还在那 1 页里,没丢。

6.4 她有两个脆弱点(debug 时要盯)

前提 破了会怎样
每页都有页码 没页码的页,"在不在"判断永远对不上 → 该补的没补回来
你本子上的页码和档案一致 如果你把同一段话重抄一遍但自己写了新页码 → 护士 A 当新页收下、补页的人又补回旧版 → 同一段话两份

第 7 步:归档员(add_messages reducer)

医生看完病要写新页,并在最后一页落笔。归档员负责把它归回档案柜,规则只有一条:认页码。

7.1 先定制度:messages 这一格永远不是"换整本"

医院规定(Annotated[..., add_messages]):任何人往 messages 里交东西,不允许把整本换掉 ,必须交给归档员合并。

→ 所以医生只交"我新写的这 1~2 页",老页不会被冲掉。

7.2 给没页码的页临时贴编号

归档员先检查:没有页码的页,现场贴一个新编号 (随机、永远不重样)。

所以"没页码"的页在后续任何一次对齐里都永远对不上旧页 → 只能当新页追加 → 同一句话出现两份。这就是"id 缺失 = 历史重复"的物理原因。

7.3 正式归档:三句话

  • 以档案柜里的 6 页为底;
  • 建一张"页码 → 第几页"的索引表;
  • 逐页核对送来的 7 页:
    • 页码档案里有 → 用新版本替换那一页,位置不动(等于"这页还是这页,内容更新")
    • 页码档案里没有 → 加在最后
  • 最后统一处理"要求删除"的页(我们不用)。

7.4 结果

7 页里 6 页是"原位换了一遍"(内容一样,等于没变),1 页是新追加的 h3 → 还是 7 页,没有重复。


三道工序的关系,一张表看完

第 5 步 护士 A 第 6 步 补页的人 第 7 步 归档员
干什么 筛出"新页" 补回"被筛掉的旧页" 按页码把新页归回档案
输入 你的本子 5 页 + 档案 6 页 护士 A 的 1 页 + 档案 6 页 档案 6 页 + 送来的 7 页
输出 1 页 7 页 7 页
关键依据 "档案里有没有" "页码在不在那 1 页里" 页码(id)
出错的后果 历史丢(本轮医生失忆) 历史缺页 历史重复(同页两份)

一句话 :第 5 步负责"挑新的",第 6 步负责"把旧的补回来",第 7 步负责"按页码合并、不重复"。三者都建立在同一个前提上------每一页都必须有页码(id)。

§2:病历里的"每一页"到底长什么样

前面讲的是怎么把两份记录并成一本 。这一节讲的是这本记录里每一页的格式 ------三类页,各有自己的栏目,后面 build_history_messages 全靠这些栏目做判断。

用病历说,一本病历里只有三种页:

页型 病历里的角色 谁写 关键栏目
HumanMessage 病人自己写的复诊主诉页("我头疼三天了") 病人进门时带来的 页码 id、正文 content(可能含图片)
AIMessage 医生的诊断/医嘱页("建议做血常规") 只有出院时医生写(respond 节点) 页码 id、正文 content(可能空白 )、附注 additional_kwargs
ToolMessage 附在医嘱后的检查单/处方页(那张卡片) 也是医生出的 tool_call_id(挂在哪条医嘱后)、正文 content(一坨 JSON)

先记住一个大前提:这三页里只有"医生"会往病历里加页。病人复诊时只是"带来"自己的主诉页(入站),并不会替医生写医嘱;中间环节(分诊、排队、检查)一页都不写。


2.1 HumanMessage:病人自己写的复诊主诉页

它从哪来:把前端的"口述"誊成标准页

350:371:.venv/Lib/site-packages/ag_ui_langgraph/utils.py 复制代码
    for message in messages:                 # 遍历前端带来的每一条 AG-UI 消息
        role = message.role                  # 取出角色:user / assistant / system / tool / reasoning / developer
        if role == "reasoning":              # 推理片段:先缓存,稍后挂到下一条 assistant 上
            pending_reasoning.append(_agui_reasoning_message_to_block(message))
            continue
        if role == "developer":              # developer 消息直接丢弃(配置在 agent 上)
            continue
        if role == "user":                   # ★病人主诉
            pending_reasoning = []           # 主诉前缓存的推理作废(接不上 assistant 了)
            # Handle multimodal content
            if isinstance(message.content, str):
                content = message.content    # 纯文本:原样
            elif isinstance(message.content, list):
                content = convert_agui_multimodal_to_langchain(message.content)
                                             # 多模态:转成 LangChain 的 [{type:"text"}, {type:"image_url"}]
            else:
                content = str(message.content)

            langchain_messages.append(HumanMessage(
                id=message.id,               # ★页码:直接沿用前端给的 id(必须非空!)
                content=content,             # 正文
                name=message.name,           # 说话人名字(群聊场景用,我们一般空)
            ))

注意两件事:

  1. 页码是前端给的,服务端不重编。所以"前端有没有给 id"直接决定后面能不能对齐。
  2. additional_kwargs 完全没被搬过来 ------前端无法往病历上盖章,created_at 只能由服务端自己打(见下一段)。

入站后第一件事:盖"到诊时间章"

333:348:src/graph.py 复制代码
    msgs = state.get("messages", [])          # 取整本病历
    if msgs:                                  # 非空才处理
        last = msgs[-1]                       # 最后一页 = 本轮主诉
        # query 提取防污染:content 可为 block list(含 image block),拼接 text block
        from src.common.image_inputs import extract_text_query
        update["query"] = extract_text_query([last])   # 只把文字捞出来当 query(图片不参与)
        # 给本轮入站消息打 created_at(ms):多轮结构化历史 item 需要时间信息
        # (LLM messages 缺时间)。同 id 覆盖、不新增消息;打点失败不阻断 ingest。
        try:
            if getattr(last, "type", "") == "human":        # 只给主诉页盖章
                ak = dict(getattr(last, "additional_kwargs", {}) or {})
                                                       # 复制原附注(避免改原件)
                if "created_at" not in ak:                  # 没盖过才盖
                    ak["created_at"] = int(time.time() * 1000)   # 毫秒时间戳
                    update["messages"] = [last.model_copy(update={"additional_kwargs": ak})]
                                                       # 复制一页 + 换上带章的新附注(★页码不变)

病历说法 :病人进门,护士在主诉页 右下角盖一个"到诊时间"的章。盖的是同一页的复印件 (页码不变),所以归档时会替换原来那页,不会多出一页。

如果这页没有页码 :复印件的页码也是空 → 归档员会给它编一个新号 → 变成两页。这就是"同一句话在病历里出现两次"。

这一页的完整栏目

栏目 值 谁设
type "human" LangChain 类名决定
id 前端消息 id(可能为 None) AG-UI
content str,或 [{"type":"text",...}, {"type":"image_url",...}] 前端
name 说话人名(一般空) 前端
additional_kwargs {"created_at": 1730000000000} ingest 打点

2.2 AIMessage:医生的诊断/医嘱页

谁写:只有 respond 会写(全仓唯一)

用"病历"语言说:只有出院前那位医生能往病历上写医嘱,其他任何岗位(分诊、排队、检查)都不能写。 这条约束是 exclude_last("去掉最后一页当本轮")能成立的根本。

代码证据:全仓搜 "messages": 的返回值,只出现在 respond 的各条返回路径里。

六种医嘱页(同一个类,不同栏目)

1570:1589:src/graph.py 复制代码
            msg_kwargs: dict = {"a2ui_operations": exhausted_ops} if exhausted_ops else {}
            exhausted_msgs: list = [AIMessage(content=response, id=message_id, additional_kwargs=msg_kwargs)]
            # 卡片契约必须与正常路径一致:前端只从 **ToolMessage** 的 content 里取
            ...
            if exhausted_cards:
                exhausted_msgs.append(ToolMessage(
                    content=json.dumps({
                        "a2ui_operations": exhausted_ops,
                        "cards": exhausted_cards,
                        "message_id": message_id,
                    }, ensure_ascii=False),
                    tool_call_id=f"a2ui-{uuid.uuid4().hex[:8]}",   # ★注意:没有 id=
                ))
1898:1905:src/graph.py 复制代码
        if card_only_no_bubble and not multi_intent_round:
            _emit_step(state, ProcessState.STEP_COMPLETE, "respond/generate", 1.0)
            new_msgs: list = [AIMessage(content="", id=message_id)]   # ★正文是空串
            if cards_info or a2ui_ops:
                new_msgs.append(ToolMessage(
                    content=json.dumps({"a2ui_operations": a2ui_ops, "cards": cards_info, "message_id": message_id}, ensure_ascii=False),
                    tool_call_id=f"a2ui-{uuid.uuid4().hex[:8]}",       # ★同样没有 id=
                ))
2042:2054:src/graph.py 复制代码
        # 增量返回:messages add_messages reducer 自动 append
        new_messages: list[AIMessage | ToolMessage] = [AIMessage(content=response, id=message_id)]
                                                       # ★医嘱页:id = message_id(uuid4().hex)
        # 4. 透传 A2UI:以 ToolMessage 追加(契约字段 + a2ui 操作,前端渲染器扫描 tool 角色)
        if a2ui_ops or cards_info:
            new_messages.append(ToolMessage(
                content=json.dumps({
                    "a2ui_operations": a2ui_ops,     # 前端渲染指令
                    "cards": cards_info,             # 卡片契约(卡片真身)
                    "message_id": message_id,        # 回指这条医嘱页
                }, ensure_ascii=False),              # ensure_ascii=False:中文不转 \uXXXX
                tool_call_id=f"a2ui-{uuid.uuid4().hex[:8]}",   # ★依然没有 id=
            ))
2065:2069:src/graph.py 复制代码
        if (cards_info and response and not multi_intent_round and
                any(_has_substantive_card(c["card_data_model"]) for c in cards_info) and
                (_is_pure_failure_apology(response) or response == "(暂无可用结果)")):
            response = " "                     # ★注意:是一个空格,不是空串
            new_messages[0].content = response # 直接改刚建好那条 AIMessage 的 content

六种变体汇总(同一类,栏目不同):

场景 content additional_kwargs 后面跟不跟检查单页
正常回答 最终文本 空 有卡片就跟
卡片即答案(早退) "" 空 跟
澄清耗尽 道歉/引导文案 {"a2ui_operations": [...]}(有 ops 时) 有卡片就跟
降级早退 模板文案 {"degradation_type": ..., "degraded": True} 不跟
纯失败兜底但卡片有货 " "(一个空格) 空 跟
闲聊 固定欢迎语 空 不跟

对 debug 的意义 :content="" 和 content=" " 在 build_history_messages 里都会被 strip() 成空、整页丢弃 (下节 2.4 讲)。所以"上一轮是纯卡片回答"时,模型看到的历史里没有医嘱正文页,只有检查单页的文本转写------不是 bug,是设计。


2.3 ToolMessage:附在医嘱后的检查单/处方页

为什么单独一页,不塞进医嘱正文

因为前端渲染器只扫 tool 角色去取卡片契约;把 13KB 的卡片 JSON 塞进正文会污染对话气泡。

这一页的完整栏目

栏目 值 说明
type "tool"
id None → 归档时由 reducer 发一个 uuid ToolMessage(...) 里没传 id=
content 单行 JSON 字符串 :{"a2ui_operations": [...], "cards": [...], "message_id": "..."} ensure_ascii=False,中文原样
tool_call_id "a2ui-" + 8位hex 形如 a2ui-3f9c1a2b,不是指某条医嘱的工具调用,只是占位标识
additional_kwargs 空

"没有 id" 在这里是正常但危险的

202:208:.venv/Lib/site-packages/langgraph/graph/message.py 复制代码
    # assign missing ids
    for m in left:                       # 档案里的旧页
        if m.id is None:
            m.id = str(uuid.uuid4())     # 没页码的,归档员现场编一个
    for idx, m in enumerate(right):      # 本次送来的页
        if m.id is None:
            m.id = str(uuid.uuid4())     # 同样现编一个(随机,永远对不上旧的)

病历说法 :检查单页出诊时没印页码,归档员收档时才随手盖一个随机号。

后果分两种:

  • 服务端自己闭环时没问题:这页一旦归档,页码就固定了,后续轮次读到的都是这个号。
  • 前端把检查单页也回传时出问题 :如果前端重发这页带的 id 和归档员当时编的不一样(几乎必然不一样),父类合并会把它当"新页"收下,我们的 override 又把档案里那页补回来 → 同一张检查单两页。

debug 时如果看到"卡片内容在历史里出现两份",先查前端是否回传了 ToolMessage、以及它的 id。


2.4 一页真实长什么样(肉眼可对)

主诉页(HumanMessage)

json 复制代码
{
  "type": "human",
  "id": "msg_7f3a...",
  "content": "再换一个",
  "name": null,
  "additional_kwargs": {"created_at": 1730000000000}
}

医嘱页(AIMessage)

json 复制代码
{
  "type": "ai",
  "id": "b1c2d3e4f5a6...",          // uuid4().hex,无横线
  "content": "为你找到 3 家食堂",
  "additional_kwargs": {},
  "tool_calls": [],
  "response_metadata": {}
}

检查单页(ToolMessage)

json 复制代码
{
  "type": "tool",
  "id": null,                        // ← 初始为 None,归档时才被编上
  "tool_call_id": "a2ui-3f9c1a2b",
  "content": "{\"a2ui_operations\": [], \"cards\": [{\"card_template_id\": \"...\", \"card_data_model\": {\"items\": [{\"title\": \"一食堂\", \"canteenId\": 158}]}, \"bubble_text\": \"\"}], \"message_id\": \"b1c2d3e4f5a6...\"}"
}

注意最后那个 content:它本身是一段 JSON 字符串 (引号里还有引号)。这就是为什么 build_history_messages 里要先 text.startswith("{") 再 json.loads 判断"这是不是卡片"。


2.5 三类页在历史加工时怎么被区别对待

这一节把 §2 和 §4 接上(后面 §4 会细讲,这里先看识别规则):

56:63:src/common/history_messages.py 复制代码
# LangChain 消息 type → 历史来源角色(用于文本前缀标注)
# "tool" → "assistant":卡片以 ToolMessage 存储(graph.py:1035),它是**助手的产出**
# ------是「只要一道」「推荐一家离我最近的」等指代的唯一对象,且携带子 Agent 依赖的
# 实体 ID(canteenId / dishId),不能丢。
_ROLE_BY_TYPE = {"human": "user", "ai": "assistant", "tool": "assistant"}

# 来源角色 → 文本前缀(防模仿:assistant 不作为消息 role 出现,只作为标注)
_SPEAKER_LABEL = {"user": "[用户]", "assistant": "[助手回复]"}
120:135:src/common/history_messages.py 复制代码
def _build_part(msg: Any) -> tuple[str, str] | None:
    """单条消息 → ``(来源角色, 文本)``;无可注入内容 → None。"""
    role = _role_of(msg)                 # ① 查表:human→user,ai/tool→assistant,其它→None
    if role is None:
        return None                      # 不认识的页型:整页丢弃
    text = _text_of(msg).strip()         # ② 取正文并去掉首尾空白(block list 只取文字部分)
    if not text:
        return None                      # ★空正文页整页丢弃(content="" / " " 都死在这)
    if text.startswith("{"):             # ③ 看着像 JSON
        card = _card_text(text)          #    是卡片?→ 转写成可读文本
        if card is not None:
            return role, card
        # 非卡片 JSON:超长且含 a2ui 标记 → 跳过(防 prompt 膨胀)
        if len(text) >= _BLOB_MIN_LEN and any(k in text for k in _BLOB_MARKERS):
            return None
    return role, text                    # ④ 普通文字:原样带走
病历页 .type 被判成 正文怎么处理
主诉页 human user 文字直接带走;图片 block 被丢掉
医嘱页 ai assistant 文字带走;空/空格 → 整页丢弃
检查单页 tool assistant(助手产出) JSON → dump_card 转成 ## 标题 + - key: value 的可读文本

2.6 病历语言总结这张表

病历概念 代码事实 后果
只有医生能写医嘱 只有 respond 返回 messages 其它节点执行时,末尾必是本轮主诉 → exclude_last 成立
医嘱可能只有一张检查单、没写结论 AIMessage(content="") 历史里这页被丢,模型只看到检查单文本
检查单页出诊时没页码 ToolMessage 不传 id 归档时随机编号;前端回传时易产生重复页
主诉页的页码是病人带来的 HumanMessage(id=message.id) 前端不给 id → 后续全乱(重复/丢历史)
病历上不认"图片"只认"文字" _text_of 只抽 text block 历史里没有图,识图信息靠 image_descriptions 单独走
印章只能医院盖 前端 additional_kwargs 不搬 created_at 只能 ingest 打;但下游历史又刻意不用 created_at

2.7 这一节的 debug 落点(三条)

  1. 看三类页的比例 :正常情况下每轮应是 Human → AI → Tool(有卡片时)。如果你看到 Human, Human, AI,说明上一轮没写医嘱(异常早退或崩了)。
  2. 看 ai 页的 content :"" / " " 是合法状态(卡片即答案),不要误判为"回复丢失"------去 tool 页看卡片内容。
  3. 看 tool 页的 id :如果服务端 messages 里同一 message_id 出现了两份 tool 页、且其中一份带前端 id、另一份是 uuid,就是"前端回传检查单"导致的重复。

要不要接着用同样方式讲 §4(这 7 条怎么变成"最近 6 条 item"送进模型)?那一节正好是这三类页被"改写成人话"的地方。

相关推荐
进击的明明14 小时前
当AI开始“边想边干”:深入聊聊ReAct,以及如何用Python手搓一个简单Agent
python·面试·agent
全栈弄潮儿15 小时前
Python实战第8期:函数
后端·python·编程语言
卷无止境17 小时前
暗流与破局:AI时代的代码安全审查工具全景指南
后端·python
卷无止境19 小时前
一人公司的全栈选型指南:让 AI Agent 写得快,也让生意跑得稳
后端·python
滴啦嘟啦哒21 小时前
【具身笔记】【π0.5 微调与仿真】五、在MuJoCo中实现可视化仿真
笔记·python·vla·具身
全栈练习生21 小时前
AI安全与红队测试
python·ai
费曼学习法21 小时前
把「发送成功」当「送达」?我排查 IM 长消息被静默吞掉的完整过程
python·ai编程·自动化运维
鸽鸽程序猿21 小时前
【项目】【在线判题系统】b端题目增删改
开发语言·python
NetX行者21 小时前
Python 工程化入门:从脚本到规范的 Python 项目结构
网络·python