逐行拆解:本轮消息如何接上一轮(面向 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 ...)
]
读法:两个条件都满足才留下:
msg.id不在 checkpoint 的 id 集合里- 且
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
215:234:.venv/Lib/site-packages/langgraph/graph/message.py
逐行:
| 行 | 含义 |
|---|---|
| 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 她实际做了七个动作
- 先让护士 A 按老规矩跑一遍 → 拿到那 1 页。
- 把档案抽出来(6 页)。
- 把护士 A 送诊的那 1 页也拿出来。
- 如果档案本来是空的(第一次来的新病人)→ 没什么可补,直接放行。
- 记下这 1 页的页码("哪些页已经在了")。
- 从 6 页档案里,挑出页码不在其中的那些 → 6 页全被挑中(因为护士 A 一页都没带)。
- 把这 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, # 说话人名字(群聊场景用,我们一般空)
))
注意两件事:
- 页码是前端给的,服务端不重编。所以"前端有没有给 id"直接决定后面能不能对齐。
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
病历说法 :检查单页出诊时没印页码,归档员收档时才随手盖一个随机号。
后果分两种:
- 服务端自己闭环时没问题:这页一旦归档,页码就固定了,后续轮次读到的都是这个号。
- 前端把检查单页也回传时出问题 :如果前端重发这页带的 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
120:135:src/common/history_messages.py
| 病历页 | .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 落点(三条)
- 看三类页的比例 :正常情况下每轮应是
Human→AI→Tool(有卡片时)。如果你看到Human, Human, AI,说明上一轮没写医嘱(异常早退或崩了)。 - 看
ai页的 content :""/" "是合法状态(卡片即答案),不要误判为"回复丢失"------去tool页看卡片内容。 - 看
tool页的 id :如果服务端messages里同一message_id出现了两份tool页、且其中一份带前端 id、另一份是 uuid,就是"前端回传检查单"导致的重复。
要不要接着用同样方式讲 §4(这 7 条怎么变成"最近 6 条 item"送进模型)?那一节正好是这三类页被"改写成人话"的地方。