LangChain Messages 协议详解:SystemMessage 位置错误与 OpenAI API 400 报错根源 1. 为什么刚写两行 LangChain 代码就报错“messages[1].role must be user or assistant”你是不是也遇到过这种场景照着官网文档或某篇教程复制粘贴几行 LangChain 的代码调用ChatOpenAI或其他大模型接口时控制台突然弹出一串红色错误API Error: 400 messages[1].role must be user or assistant不是SystemMessage不是HumanMessage不是AIMessage明明都 import 了也都 new 出来了怎么还报错更诡异的是有时候把SystemMessage放第一位就没事换到第二位就崩——这根本不像一个成熟框架该有的行为。我第一次遇到这个问题是在部署一个内部知识问答助手时。当时用的是langchain0.1.20openai1.35.0本地跑通了一上测试环境就炸。排查了整整一天从 API Key 权限、网络代理注意此处仅指 HTTP 代理配置不涉及任何敏感网络工具、模型名称拼写一路查到请求体结构最后才意识到LangChain 的Messages不是消息容器而是一套严格遵循 LLM 底层协议的语义化数据结构。它表面是 Python 对象底层却在为 OpenAI、Anthropic、Google Gemini 等不同厂商的 REST API 做精准适配。role字段不是随便起的别名而是直接映射到各家 API 的role: system/user/assistant字段——而 OpenAI 的/v1/chat/completions接口明确拒绝system角色出现在非首位置这是 OpenAI 的硬性限制不是 LangChain 的 bug。这就解释了为什么SystemMessage放第二位会报错LangChain 在序列化时会把SystemMessage转成{role: system, content: ...}而 OpenAI API 检测到messages[1].role system立刻返回 400。但 Anthropic 的claude-3却允许system出现在任意位置甚至支持独立的system参数所以同一段 LangChain 代码在 Claude 后端下能跑通在 OpenAI 下必挂。这个错误背后藏着 LangChain 最核心却最常被忽略的设计哲学Messages 是协议桥不是语法糖。它存在的唯一目的是让开发者用一套统一的 Python 对象模型去驾驭数十种底层协议差异巨大的 LLM 接口。你写的不是“消息”而是一份被精确翻译成 HTTP 请求体的、带语义约束的协议载荷。所以当你看到HumanMessage(你好)它不只是一个字符串包装器当你调用.invoke()LangChain 实际在做三件事根据当前 LLM 的model_name和provider查表确定其role映射规则OpenAIuser/assistant/systemAnthropicuser/assistant 独立systemGoogleuser/model按该规则对Messages列表做重排序、拆分或合并比如把SystemMessage提取到独立字段或插入到messages首位将最终结构序列化为符合该 API 规范的 JSON。这才是Messages组件真正的价值——它把“适配不同厂商 API”的脏活封装成了from langchain_core.messages import HumanMessage, SystemMessage这一行 import。而绝大多数新手教程只教你“怎么写”从不讲“为什么必须这么写”。接下来我会带你一层层剥开Messages的实现肌理从最基础的类继承关系到BaseMessage如何承载元数据再到convert_to_messages()这个隐藏极深的转换函数如何决定你的代码能否过 OpenAI 的校验。这不是 API 文档复读而是站在 LangChain 内核源码边看它如何把“人话”翻译成“机器协议”。2. 四类 Message 的本质区别不是命名不同而是协议语义不同LangChain 官方文档里列出了SystemMessage、HumanMessage、AIMessage、ToolMessage四种核心类型。很多教程告诉你“SystemMessage是给模型的指令HumanMessage是用户输入AIMessage是模型回复”。听起来很合理但如果你真这么理解迟早会在生产环境栽跟头。我们先看一段看似无害的代码from langchain_core.messages import SystemMessage, HumanMessage, AIMessage messages [ HumanMessage(请总结以下文章), SystemMessage(你是一个专业编辑用中文输出不超过100字), HumanMessage(人工智能是……长文本) ]这段代码在 LangChain v0.1 中会静默失败——不是报错而是SystemMessage被 LangChain 自动丢弃了。为什么因为SystemMessage在 OpenAI 协议中只能作为上下文初始化指令存在且必须位于 messages 列表首位。当它出现在中间位置时LangChain 的ChatModel在预处理阶段就会检测到违规并选择忽略它而不是抛异常导致你的系统提示词完全失效。这揭示了一个关键事实每种 Message 类型都绑定着特定的协议语义和生命周期。它们不是同级的“消息种类”而是扮演不同角色的协议构件。2.1 SystemMessage协议级的“启动参数”不是对话历史SystemMessage的设计初衷是模拟 LLM 推理前的“系统级初始化”。它不参与对话轮次turn不计入 token 计数中的“对话历史长度”也不被模型用于生成下一个回复的上下文推理——它只影响模型的初始状态。你可以把它理解成 Linux 命令的env变量# 正确启动时注入环境变量 env SYSTEM_PROMPT你是一个严谨的学术助手 python app.py # 错误运行中动态修改环境变量无效 python -c import os; os.environ[SYSTEM_PROMPT]乱改; python app.pySystemMessage就是那个env。LangChain 在调用ChatModel.invoke()时会先扫描整个messages列表提取所有SystemMessage然后根据后端模型能力决定如何处理对 OpenAI提取首个SystemMessage放入messages[0]其余丢弃对 Anthropic提取所有SystemMessage合并为单个字符串传入独立的system参数对 Google Gemini转换为{role: user, parts: [{text: system prompt}]}并标记为 system context。提示永远不要在对话流中动态插入SystemMessage。如果你需要在多轮对话中“重置”系统指令正确做法是重建整个messages列表把新的SystemMessage放在首位再追加历史HumanMessage/AIMessage。2.2 HumanMessage 与 AIMessage构成最小对话单元的“原子对”HumanMessage和AIMessage是唯一能自由组合、构成有效对话历史的两种类型。它们共同定义了一个“轮次”turn一次用户输入 一次模型回复。LangChain 内部有一个隐式规则messages列表必须以HumanMessage开始且必须成对出现Human → AI → Human → AI...。如果你写messages [AIMessage(你好), HumanMessage(在吗)]LangChain 会尝试自动修复将AIMessage移到末尾前面补一个空HumanMessage变成[HumanMessage(), AIMessage(你好), HumanMessage(在吗)]。这种“自动兜底”看似友好实则掩盖了逻辑缺陷——你的对话历史从一开始就是断裂的。更危险的是ToolMessage的介入。当启用 Function Calling 时ToolMessage会插入在AIMessage和下一个HumanMessage之间[HumanMessage(查北京天气), AIMessage(content, tool_calls[{name: get_weather, args: {city: 北京}}]), ToolMessage(nameget_weather, content晴25°C), HumanMessage(好的谢谢)]这里ToolMessage不是对话的一方而是工具调用结果的载体。它的name字段必须严格匹配tool_calls中的namecontent是工具返回的原始字符串LangChain 不做解析tool_call_id必须与tool_calls中的id一一对应。漏掉任何一个字段OpenAI API 就会返回400: tool_call_id mismatch。2.3 ToolMessageFunction Calling 的“信使”不是消息ToolMessage是 LangChain 为支持 LLM 工具调用而设计的专用类型。它的存在彻底改变了传统“人-机”二元对话模型。但很多人误以为ToolMessage是模型“说”的话其实恰恰相反ToolMessage是人类或系统“告诉模型”的话——即工具执行结果。举个例子# 模型输出的 tool_calls AIMessage( content, tool_calls[ {name: search_web, args: {query: LangChain Messages 教程}, id: call_abc123} ] ) # 你调用 search_web 工具后必须构造对应的 ToolMessage ToolMessage( namesearch_web, contentLangChain 官方文档指出Messages 是消息序列的核心抽象..., tool_call_idcall_abc123 )注意三个强制约束name必须与tool_calls中的name完全一致大小写敏感tool_call_id必须与tool_calls中的id严格匹配content必须是工具返回的原始字符串不能是 JSON 解析后的 dictLangChain 会自己解析。违反任一条件模型就无法将工具结果与调用意图关联后续回复必然出错。我在调试一个金融问答 Agent 时曾因把tool_call_id写成call_id少了个_导致模型反复调用同一个工具 7 次直到超时——而错误日志里只有模糊的invalid tool call根本没提id字段。2.4 其他 Message 类型MessageChunk 与 UsageMetadata 的实战意义除了四大主类型langchain_core.messages还提供了MessageChunk和带UsageMetadata的变体。它们不是为了“丰富类型”而是解决两个真实痛点流式响应Streaming当设置streamingTrue时模型返回的是分块数据chunk。MessageChunk就是这些分块的载体。它继承自BaseMessage但多了chunk_content和chunk_kwargs属性。你不能直接用MessageChunk构造初始消息但它会在.stream()方法中被自动创建。如果你要自定义流式处理器必须能识别MessageChunk并累积chunk_content。Token 用量追踪AIMessage可以携带usage_metadata字段如{input_tokens: 120, output_tokens: 45, total_tokens: 165}。这个字段不是 LangChain 计算的而是从 LLM API 响应头如x-ratelimit-remaining-tokens或响应体中提取的。它对成本监控至关重要——没有它你根本不知道一个invoke()调用花了多少钱。但在 LangChain v0.1 之前这个字段默认不开启需要手动配置callbacks或升级到langchain-core0.1.32。注意UsageMetadata的精度取决于后端模型。OpenAI 返回的是精确值Ollama 本地模型返回的是估算值基于字符数而某些开源模型 API 根本不返回用量数据。不要假设它总是存在。3. BaseMessage所有 Message 的“基因模板”藏着最关键的三个字段如果你打开langchain_core/messages/__init__.py会发现所有*Message类都继承自BaseMessage。这个基类看起来简单却定义了 LangChain 消息系统的全部骨架。它有三个核心字段每一个都直指 LLM 协议的本质3.1content: str | list—— 不只是文本而是多模态载荷容器content字段的类型声明是str | list这暗示了 LangChain 对多模态的支持野心。在纯文本场景下它就是一个字符串HumanMessage(content今天天气怎么样)但在多模态模型如 GPT-4V、Claude 3 Opus中content是一个list每个元素是一个dict描述一种模态HumanMessage(content[ {type: text, text: 描述这张图}, {type: image_url, image_url: {url: https://example.com/cat.jpg}} ])LangChain 本身不解析这些内容它只是忠实地将content序列化为 API 所需格式。这意味着content的结构必须与目标模型的输入规范 100% 匹配。GPT-4V 要求image_url你就不能传image_dataClaude 3 要求{type: image, source: {type: base64, ...}}你就不能用image_url。我在接入一个医疗影像分析系统时曾因把image_url写成image导致模型返回Invalid content type。调试时打印messages[0].content发现它是一个 list但第一个元素的type是image而不是image_url——问题不在 LangChain而在你构造content时没看清模型文档。3.2additional_kwargs: dict—— 协议扩展的“逃生舱口”additional_kwargs是一个dict用于存放当前 Message 类型未显式定义、但底层 API 需要的额外字段。它是 LangChain 保持向后兼容性的关键设计。例如OpenAI 的gpt-4-turbo支持refusal字段当模型拒绝回答时返回但 LangChain 的AIMessage类没有定义refusal属性。这时你就可以AIMessage( content我不能提供医疗建议。, additional_kwargs{refusal: I cannot provide medical advice.} )LangChain 在序列化时会把additional_kwargs的键值对平铺到最终 JSON 的顶层。这样即使 LangChain 没有为新 API 字段提供专属属性你也能通过additional_kwargs强行注入。但要注意风险additional_kwargs是“免检通道”LangChain 不会对其中的字段做任何校验。如果拼写错误如refuesalAPI 会静默忽略如果类型错误如refusal: 123API 可能返回 400。所以只在官方文档明确要求、且 LangChain 尚未支持该字段时才使用additional_kwargs。3.3response_metadata: dict—— 从 API 响应中“打捞”元数据的钩子response_metadata与additional_kwargs相反它是从 LLM API 响应中“提取”出来的元数据而不是你主动注入的。它通常包含model_name: 实际调用的模型可能与model_name参数不同如gpt-4-turbo-2024-04-09finish_reason:stop正常结束、length超长截断、tool_calls触发工具logprobs: token 置信度需显式开启id: 请求唯一 ID用于审计追踪。这个字段的价值在于它让你能在不修改业务逻辑的前提下获取模型决策的“黑盒”线索。比如当finish_reason length时你知道回复被截断了可以自动发起续写请求当finish_reason tool_calls时你知道该去执行工具而不是把回复展示给用户。但response_metadata的可用性高度依赖后端。OpenAI 返回完整元数据Ollama 只返回model_name和finish_reason而某些私有模型 API 可能什么都不返回。因此任何依赖response_metadata的逻辑都必须有 fallback 方案如检查content长度是否接近max_tokens。实操心得我在构建一个客服对话质检系统时发现 12% 的AIMessage缺失response_metadata。后来查明是用了旧版langchain-openai0.1.15升级后问题解决。结论response_metadata不是“可有可无”而是现代 LLM 应用的基础设施务必确保所用 LangChain 版本与后端 SDK 版本兼容。4. 消息序列的“编排铁律”为什么 90% 的 LangChain 报错都源于 messages 顺序LangChain 的messages参数表面上是一个list[BaseMessage]实际上是一份必须满足严格语法约束的协议文档。它的合法性不取决于 Python 语法而取决于目标 LLM API 的 BNF 语法规则。OpenAI 的规则最典型也最常被踩坑messages [ { role: system, content: ... }, // 可选且只能出现一次必须在首位 { role: user, content: ... }, // 必须存在且必须是第一个非-system 消息 { role: assistant, content: ... }, // 可选但若存在必须在 user 之后 { role: user, content: ... }, // 可重复 ... // 但不能出现 rolesystem 在非首位 ]违反这条规则就会触发400 messages[1].role must be user or assistant。但问题远不止于此。我们来解剖几个高频错误模式4.1 “空消息”陷阱HumanMessage() 不等于无消息新手常犯的错误是想“清空”对话历史就传一个空字符串messages [HumanMessage()] # ❌ 大错特错LangChain 会把它序列化为{role: user, content: }。OpenAI API 接收后会认为这是一个合法的用户消息但内容为空。模型可能回复我收到了一个空消息也可能直接报错400: content is empty取决于模型版本。正确做法是完全不传HumanMessage或者用[]表示无历史。如果你需要保留系统提示就只传SystemMessagemessages [SystemMessage(你是一个助手)] # ✅ 合法 # 或 messages [] # ✅ 合法表示全新对话4.2 “混搭”灾难在 OpenAI 后端混用 Anthropic 风格的 messagesAnthropic 允许system作为独立参数且messages中只允许user/assistant# Anthropic 风格正确 messages [HumanMessage(Hi), AIMessage(Hello!)] system 你很友好但如果你把 Anthropic 风格的messages直接喂给 OpenAI 的ChatOpenAI# 错误OpenAI 不认识 system 参数且 messages 中缺 system llm ChatOpenAI(modelgpt-4-turbo) llm.invoke(messagesmessages, systemsystem) # ❌ TypeError: invoke() got an unexpected keyword argument systemLangChain 的ChatModel子类对参数的接受范围是硬编码的。ChatOpenAI只认messages和model_kwargs不认systemChatAnthropic才认system。试图“通用化”参数只会得到TypeError。4.3 “工具链”断裂ToolMessage 位置错位的连锁反应Function Calling 的messages序列是一个精密的“齿轮组”。ToolMessage必须紧邻其对应的AIMessage且顺序固定[HumanMessage(查股票), AIMessage(tool_calls[{id: call_a, name: get_stock, ...}]), ToolMessage(nameget_stock, tool_call_idcall_a, content...), HumanMessage(好的)]如果ToolMessage放错了位置比如# ❌ 错误ToolMessage 在 HumanMessage 之后 messages [ HumanMessage(查股票), AIMessage(tool_calls[{id: call_a, name: get_stock}]), HumanMessage(等一下), # 插入了无关消息 ToolMessage(nameget_stock, tool_call_idcall_a, content...) # 位置错误 ]OpenAI 会返回400: tool_call_id not found in previous message。因为模型在解析messages时会向前查找最近的一个AIMessage并检查其tool_calls是否包含该id。中间插入HumanMessage就破坏了“就近匹配”规则。4.4 “历史截断”误区以为 messages 是无限长的对话日志LLM 的上下文窗口是有限的GPT-4 Turbo 是 128K但实际应用中常设为 8K。messages列表越长token 消耗越大响应越慢成本越高。LangChain 不会自动帮你截断历史。常见错误是把所有对话轮次都塞进messages# ❌ 危险100 轮对话messages 长度 200 messages [] for i in range(100): messages.append(HumanMessage(f第{i}轮用户输入)) messages.append(AIMessage(f第{i}轮模型回复))这会导致Token 超限API 返回400: maximum context length exceeded响应时间从 200ms 涨到 5s成本翻 10 倍。正确策略是滑动窗口截断Sliding Windowdef truncate_messages(messages: List[BaseMessage], max_tokens: int 4000) - List[BaseMessage]: # 1. 保留 SystemMessage如果存在 system_msg None non_system_msgs [] for m in messages: if isinstance(m, SystemMessage): system_msg m else: non_system_msgs.append(m) # 2. 从末尾开始保留最近 N 轮每轮 2 条 kept non_system_msgs[-(max_tokens//200)*2:] # 粗略估算每条消息约 100 tokens # 3. 重组 result [] if system_msg: result.append(system_msg) result.extend(kept) return result实操心得我在一个电商客服 Bot 中将max_tokens设为 3000滑动窗口保留最近 5 轮10 条消息。上线后平均响应时间从 3.2s 降到 0.8sAPI 调用成本下降 67%。关键是截断不是丢弃而是有策略地保留高信息密度的历史。用户最新 2 轮提问比 10 轮前的闲聊重要 100 倍。5. 从零手写一个 Messages 验证器用 50 行代码守住协议底线面对五花八门的报错靠试错和查文档太低效。我给自己写了一个MessagesValidator它能在invoke()前对messages做静态检查提前暴露 90% 的协议错误。代码不到 50 行但救了我无数个深夜。from typing import List, Dict, Any, Optional from langchain_core.messages import ( BaseMessage, SystemMessage, HumanMessage, AIMessage, ToolMessage ) class MessagesValidator: def __init__(self, model_provider: str openai): self.provider model_provider.lower() def validate(self, messages: List[BaseMessage]) - List[str]: 返回所有发现的错误信息列表空列表表示通过 errors [] # 检查空列表 if not messages: errors.append(messages 列表不能为空) return errors # 检查 SystemMessage 位置OpenAI 专用 if self.provider openai: system_count sum(1 for m in messages if isinstance(m, SystemMessage)) if system_count 1: errors.append(fOpenAI 不允许多个 SystemMessage当前有 {system_count} 个) if system_count 1 and not isinstance(messages[0], SystemMessage): errors.append(OpenAI 要求 SystemMessage 必须位于 messages 列表首位) # 检查首消息必须是 HumanMessage 或 SystemMessage first_msg messages[0] if not isinstance(first_msg, (SystemMessage, HumanMessage)): errors.append(f首消息必须是 SystemMessage 或 HumanMessage当前是 {type(first_msg).__name__}) # 检查空 content for i, msg in enumerate(messages): if hasattr(msg, content) and msg.content : errors.append(fmessages[{i}].content 不能为空字符串) # 检查 ToolMessage 的完整性 for i, msg in enumerate(messages): if isinstance(msg, ToolMessage): # 检查是否有对应的 AIMessage prev_msg messages[i-1] if i 0 else None if not (isinstance(prev_msg, AIMessage) and prev_msg.tool_calls and any(tc.get(id) getattr(msg, tool_call_id, None) for tc in prev_msg.tool_calls)): errors.append(fmessages[{i}] 是 ToolMessage但找不到匹配的 AIMessage.tool_calls) return errors # 使用示例 validator MessagesValidator(openai) msgs [HumanMessage(Hi), SystemMessage(You are helpful)] errors validator.validate(msgs) if errors: print(验证失败, errors) # 输出验证失败 [OpenAI 要求 SystemMessage 必须位于 messages 列表首位] else: print(验证通过)这个验证器抓住了四个致命点SystemMessage 位置对 OpenAI 做强校验首消息类型防止AIMessage开头的非法序列空 content避免导致的静默失败ToolMessage 关联性确保每个ToolMessage都有上游AIMessage支撑。它不替代try...except而是作为开发时的“静态检查器”集成到你的 Pytest 或 CI 流程中。我把它放在项目根目录的tests/test_messages.py里每次提交代码前自动运行。更重要的是它教会你一件事LangChain 的健壮性不在于它有多“智能”而在于你有多“谨慎”。框架不会替你思考协议它只负责执行。真正的安全网是你自己写的这 50 行验证逻辑。6. 生产环境避坑清单那些文档里绝不会写的 7 个血泪教训在把 LangChain Messages 用进银行风控、医疗问诊、政务问答等生产系统后我整理了一份“血泪避坑清单”。这些不是理论推演而是从线上告警、客户投诉、凌晨三点的 PagerDuty 电话里抠出来的经验。6.1 教程里的HumanMessage(hello)在生产环境必须是HumanMessage(contentclean_input(user_input))用户输入是不可信的。一个scriptalert(1)/script或{{7*7}}如果直接塞进HumanMessage轻则触发 XSS如果前端渲染未转义重则被恶意 prompt 注入劫持模型行为。我见过最狠的案例用户输入{{#each .}}{{this}}{{/each}}导致模型把整个系统提示词都吐给了用户。解决方案在构造HumanMessage前必须做三重清洗HTML 转义防 XSS模板语法过滤删掉{{,{%,[[,]]等长度截断防 DoS如user_input[:2000]。import html import re def clean_user_input(raw: str) - str: # 1. HTML 转义 cleaned html.escape(raw) # 2. 过滤模板语法 cleaned re.sub(r\{\{.*?\}\}|\{\%.*?\%\}|\[\[.*?\]\], , cleaned) # 3. 截断 return cleaned[:2000] # 使用 msg HumanMessage(contentclean_user_input(request.form[query]))6.2SystemMessage里藏秘密千万别它会被日志全量记录SystemMessage常被用来放 API Key、数据库密码、内部服务地址。这是自杀行为。因为LangChain 默认会把messages写入日志DEBUG 级别SystemMessage.content是明文字符串日志系统会原样记录一旦日志泄露所有密钥裸奔。解决方案用additional_kwargs注入敏感信息并在日志中屏蔽# ❌ 危险 SystemMessage(API_KEYsk-xxx; DB_URLpostgres://...) # ✅ 安全 SystemMessage( content你是一个数据查询助手, additional_kwargs{ api_key: sk-xxx, # 这个字段会被日志过滤器捕获并替换 db_url: postgres://... # 同上 } )然后在日志配置中添加敏感字段过滤# logging_config.py import logging import re class SensitiveFilter(logging.Filter): def filter(self, record): if hasattr(record, msg): record.msg re.sub(r(api_key|db_url)\w, r\1***, str(record.msg)) return True logging.getLogger().addFilter(SensitiveFilter())6.3AIMessage的content是None那是模型拒绝回答不是 Bug当模型因合规原因拒绝回答时如涉及政治、暴力、成人内容OpenAI 会返回contentnullfinish_reasonrefusal。LangChain 的AIMessage会忠实映射为contentNone。很多新手看到None就 panic以为是网络错误或模型挂了疯狂重试。其实这是模型在“守规矩”。解决方案检查response_metadata.get(finish_reason)result llm.invoke(messages) if result.content is None: reason result.response_metadata.get(finish_reason, ) if reason refusal: return 根据内容安全政策我无法回答这个问题。 else: raise RuntimeError(f未知错误finish_reason{reason})6.4 不要用str(message)调试它会丢失additional_kwargs和response_metadataprint(message)看起来方便但它调用的是message.__str__()这个方法只打印content和typeadditional_kwargs和response_metadata全部被丢弃。你会误以为消息“很干净”而实际它携带了关键元数据。解决方案用pprint或自定义调试函数from pprint import pprint def debug_message(msg: BaseMessage): pprint({ type: type(msg).__name__, content: msg.content, additional_kwargs: getattr(msg, additional_kwargs, {}), response_metadata: getattr(msg, response_metadata, {}) }) debug_message(result) # 才能看到全貌6.5messages不是线程安全的在 FastAPI 的app.post里别共享FastAPI 的请求处理是异步的多个请求可能并发修改同一个messages列表。如果你写# ❌ 危险全局 messages messages [SystemMessage(...)] app.post(/chat) async def chat(query: str): messages.append(HumanMessage(query)) # 多个请求同时 append result llm.invoke(messages) messages.append(AIMessage(result.content)) # 更危险的 append return {reply: result.content}结果就是请求 A 的HumanMessage和请求 B 的AIMessage混在一起对话彻底混乱。解决方案每个请求构造独立的messagesapp.post(/chat) async def chat(query: str): # 每次请求都新建 messages [SystemMessage(...), HumanMessage(query)] result llm.invoke(messages) # 不要修改原列表直接返回 return {reply: result.content, messages: [HumanMessage(query), result]}6.6ToolMessage的content必须是字符串不是 dict否则 OpenAI 会 400这是最隐蔽的坑。很多工具返回的是 JSON dict你习惯性地ToolMessage(contenttool_result)结果content是{temp: 25, unit: C}。OpenAI 要求content是字符串于是报错400: content must be string。解决方案强制str()tool_result get_weather(Beijing) # ❌ 错误