model.chat()返回的response不一定是字符串。这句话我第一次跟同事说的时候对方的第一反应是不可能文档里写的- str。但只要你在 ChatGLM3-6B 的 history 第一项里塞了带tools字段的 system 消息模型一旦决定调工具这个返回值就会变成一个{name: ..., parameters: {...}}的字典。而 LangChain 那边AIMessage期待的是带id、name、args三段结构的tool_calls列表args还必须是 dict 而不是 JSON 字符串。两边的数据结构从类型到字段名全对不上这就是把 ChatGLM3-6B 接进 LangChain 的function call链路时绝大多数报错的源头。这篇记录写的就是这件事从model.chat()的真实返回值开始一层一层翻译成 LangChain 的BaseChatModel能认的格式最后跑通一个完整的工具调用循环。适合两类人看——手里已经有 ChatGLM3-6B 权重、想把它塞进 LangChain Agent 的开发者以及正在给其他非 OpenAI 格式的开源模型写适配层的人。下面的代码都按能跑的标准写不是伪代码中间踩到的坑我会把排查链路完整留出来你可以照着复现。1. 先承认一个事实两边说的工具调用不是同一件事1.1 字段对照表暴露的错位很多适配代码写崩是因为作者默认大家用的都是 OpenAI 那套toolsschema应该能直接透传。格式确实能透传但返回值的形态完全不是一回事。先把两侧的数据结构摊开对比问题一眼就能看出来。维度ChatGLM3-6B 原生LangChainAIMessage调用载体history末尾roleassistant的项靠metadata字段携带工具名tool_calls列表独立于content参数结构parameters解析后是 dictargs必须是 dict不能是字符串调用 ID完全不产生必需ToolMessage.tool_call_id要和它严格匹配工具定义位置history[0]的 system 项里的tools字段bind_tools()传入最终由模型自己消化返回值类型有工具调用时是dict否则是str恒为AIMessage工具结果回填{role: observation, content: ...}ToolMessage(content..., tool_call_id...)看第三行和最后一行——ChatGLM3 根本不产生调用 ID。它是一问一答的串行结构observation靠出现顺序和前面的 assistant 项隐式对应。LangChain 的 Agent 却要拿 ID 做匹配校验ToolMessage的tool_call_id对不上AIMessage.tool_calls里的id会直接抛异常。这个 ID 没人给你只能自己造。1.2 为什么不能用一个ChatOpenAI包装糊过去有人会想ChatGLM3 的 tools schema 就是 OpenAI 格式那能不能直接拿ChatOpenAI指向本地服务技术上可以前提是你得先起一个 OpenAI 兼容的 HTTP 服务而且那个服务内部还是要做一次ChatGLM3 原生结构 → OpenAI 结构的转换。这一层转换你迟早要写只是从写在适配层变成写在服务端还多了一次网络序列化。更重要的是history 的完整性。ChatGLM3 的对话是有状态的它的history里保存的不只是纯文本还有metadata这种结构化痕迹。如果你走 OpenAI 兼容层这些痕迹会被压平成纯文本模型在第二轮就忘记自己刚才调过什么工具开始胡编工具名。我在第一版里就是这么干的第二轮必定崩。1.3 适配层的目标边界想清楚要做什么比怎么写更重要。我给这层适配定的目标是三条单向翻译进 LangChain 的消息列表出 ChatGLM3 的(query, history)二元组返回时反向翻译成AIMessage。无损往返AIMessage必须能携带足够的原始信息让下一轮重建history时和模型上次看到的一模一样。不碰模型内部不改权重、不 patchmodeling_chatglm.py所有处理都在外部完成。这样换版本、换设备都不会重来。第三条是底线。我见过有人为了省事直接改process_response结果升级权重之后整个仓库对不上回滚都回不干净。2. 拆开model.chat()的返回值一个会变类型的 response2.1 一次最小验证先看清楚它到底吐什么动手写适配层之前先花十分钟跑一个最小脚本把response和history的真实形态打印出来。这一步省不得因为不同小版本之间process_response的内部实现有差异只有实测才是准的。import json from transformers import AutoTokenizer, AutoModel LOCAL_MODEL_DIR ./models/chatglm3-6b tokenizer AutoTokenizer.from_pretrained(LOCAL_MODEL_DIR, trust_remote_codeTrue) model AutoModel.from_pretrained( LOCAL_MODEL_DIR, trust_remote_codeTrue, device_mapauto ).eval() tools [{ type: function, function: { name: get_weather, description: 查询指定城市的实时天气, parameters: { type: object, properties: { city: {type: string, description: 城市名称例如北京} }, required: [city], }, }, }] history [{ role: system, content: 你是一个助手可以调用工具完成用户请求。, tools: tools, }] response, history model.chat( tokenizer, 北京今天天气怎么样, historyhistory, temperature0.01, max_length2048, ) print(response type:, type(response)) print(response:, response) print(last history item:, json.dumps(history[-1], ensure_asciiFalse))实测下来输出大概是这个形状response type: class dict response: {name: get_weather, parameters: {city: 北京}} last history item: {role: assistant, metadata: get_weather, content: \n{\city\: \北京\}}两个结论response是 dict不是字符串history最后一条的metadata字段就是工具名content是模型吐出的原始参数文本。如果你把response直接拼进字符串或者丢给response.strip()立刻就是AttributeError。2.2 history 里的 metadata 才是真正的调用记录我一开始以为response里的 dict 就够了只要把它转成tool_calls就完事。结果第二轮模型开始乱回要么说我没有这个工具要么把参数编错。原因是我重建history的时候把那条roleassistant的原始项扔了换成了一条{role: assistant, content: }。模型的训练数据里工具调用这一轮是长成|assistant|get_weather\n{参数}这个特定形态的。你把它压成空字符串等于告诉模型我刚才什么都没输出它自然接不上。所以metadata这一项必须原样保留。我的做法是把它塞进AIMessage.response_metadata键名叫glm_history_item下一轮原样取出来放回去ai_message AIMessage( content, tool_calls[{ name: response[name], args: response[parameters], id: fcall_{uuid.uuid4().hex[:12]}, }], response_metadata{glm_history_item: history[-1]}, )这么一来即便中间经过了 Agent、Callback、缓存、序列化只要AIMessage还在原始项就丢不了。2.3 那个eval你最好自己换掉翻 ChatGLM3 的process_response源码会发现它用eval把模型输出的参数文本转成 dict。这在官方 demo 里没问题但你要把它接到生产链路上就得掂量一下模型输出是不可控的eval能执行的不只是字面量。我的处理是在外层再兜一层json.loads失败就回退到模型给的原始 dict。多数情况下response[parameters]已经解析好了不需要我再动手只有在拿到非 dict 类型的脏数据时才走兜底逻辑def _safe_args(raw): if isinstance(raw, dict): return raw if isinstance(raw, str): try: return json.loads(raw) except json.JSONDecodeError: # 交给上层标记为解析失败不抛异常打断整个 Agent return {} return {}这里我选择解析失败返回空字典而不是抛错因为 Agent 链路里一个工具参数错了不该让整轮对话崩掉让它带着空参数调一次、拿个错误结果回来反而更容易让模型自己纠正。3. 把 LangChain 的消息流翻译成 ChatGLM3 的 history3.1 SystemMessage 不是可有可无的装饰ChatGLM3 的build_chat_input在序列化的时候只有碰到{role: system, tools: [...]}这一项才会把工具定义拼进 prompt。如果你没有 system 项或者有 system 项但没带tools字段模型看到的就是一个纯聊天场景压根不会输出工具调用格式。这带来一个硬性约束适配层必须永远保证history[0]是一条 system哪怕用户没传SystemMessage也要塞一条默认的进去。而且要带tools——只要这一轮绑了工具。还有个细节值得说工具定义会被json.dumps(..., indent1)序列化进 system token。所以你在 system content 里再手写一遍你可以使用 get_weather 工具查询天气是多余的而且会和工具定义抢注意力。我实测过system content 写得越花哨模型调错工具的概率越高。干净的一句话指令反而最稳。def _build_system_item(self, messages, glm_tools): texts [str(m.content) for m in messages if isinstance(m, SystemMessage)] content \n.join(t for t in texts if t).strip() or 你是一个助手可以调用工具完成用户请求。 item {role: system, content: content} if glm_tools: item[tools] glm_tools return item3.2 AIMessage 的 tool_calls 需要回译成 metadata重建 history 的时候AIMessage有两种形态要分开处理。第一种是带glm_history_item的——上一轮我们自己的模型产出的直接原样放回最省事也最准确。第二种是不带的比如用户手工构造的AIMessage或者从别的模型切换过来的历史。这种情况只能从tool_calls反推elif isinstance(m, AIMessage): raw (m.response_metadata or {}).get(glm_history_item) if raw: history.append(raw) elif m.tool_calls: for tc in m.tool_calls: history.append({ role: assistant, metadata: tc[name], content: \n json.dumps(tc[args], ensure_asciiFalse), }) else: history.append({role: assistant, content: str(m.content)})注意那个\n前缀。它不是随便加的——ChatGLM3 的解析逻辑是拿|assistant|之后的第一行当元数据工具名剩下的当内容。所以拼出来的文本必须是工具名 换行 参数的结构少了这个换行参数会被当成工具名的一部分模型下一轮就蒙了。3.3 ToolMessage 到 observation 的落位规则ToolMessage的转换看着最简单其实最容易出问题。elif isinstance(m, ToolMessage): history.append({ role: observation, content: str(m.content), })坑在哪在顺序。ChatGLM3 的observation是靠位置和前面的 assistant 项隐式配对的它不认tool_call_id。如果你有并行工具调用两条ToolMessage连着来模型会按出现顺序一一对应。所以适配层必须保证messages里的ToolMessage顺序和当初AIMessage.tool_calls的顺序完全一致。LangChain 的ToolNode默认是保持顺序的但如果你自己写了并发执行as_completed出来的顺序是乱的一定要按tool_calls的索引重新排序再回填。另外ToolMessage.content建议做一次裁剪。工具返回的 JSON 动辄几千字符全塞进 history 会迅速吃掉 context。我的做法是超过 2000 字符就截断并加个...[truncated]标记实测对模型判断影响很小。3.4 连续同角色消息的合并ChatGLM3 的对话模板是严格交替的user → assistant → user → assistant。如果messages里出现连续两条HumanMessage比如系统提示后用户又追加了一条直接拼进去会变成|user|...|user|...模型会开始输出奇怪的东西。处理方式很简单在 append 之前检查上一条的 role相同就合并 contentdef _append_merged(history, role, content): if history and history[-1][role] role and role in (user, observation): history[-1][content] history[-1][content] \n content else: history.append({role: role, content: content})observation我也一起合并了因为多个工具结果连着回来时合并成一条比拆成多条更接近训练分布。4. 从BaseChatModel继承要实现的其实只有四个钩子4.1_generate的输入输出契约LangChain 里自定义 ChatModel核心就是_generate。它的签名和返回结构必须严格照做否则上层 Agent 会拿到一个看起来能跑但字段缺失的结果然后在某个莫名其妙的地方崩。from typing import Any, List, Optional from langchain_core.callbacks import CallbackManagerForLLMRun from langchain_core.language_models.chat_models import BaseChatModel from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage, ToolMessage from langchain_core.outputs import ChatGeneration, ChatResult from langchain_core.utils.function_calling import convert_to_openai_tool class ChatGLM3ChatModel(BaseChatModel): model: Any tokenizer: Any temperature: float 0.01 max_new_tokens: int 1024 glm_tools: Optional[List[dict]] None property def _llm_type(self) - str: return chatglm3 def _generate( self, messages: List[BaseMessage], stop: Optional[List[str]] None, run_manager: Optional[CallbackManagerForLLMRun] None, **kwargs: Any, ) - ChatResult: glm_tools kwargs.get(glm_tools) or self.glm_tools query, history self._to_glm_messages(messages, glm_tools) prompt_tokens self._count_prompt_tokens(query, history) response, new_history self.model.chat( self.tokenizer, query, historyhistory, temperatureself.temperature, max_lengthprompt_tokens self.max_new_tokens, ) ai_message self._to_ai_message(response, new_history) return ChatResult( generations[ChatGeneration(messageai_message)], llm_output{ model_name: chatglm3-6b, token_usage: { prompt_tokens: prompt_tokens, completion_tokens: 0, total_tokens: prompt_tokens, }, }, )llm_output里的 token 数我是粗估的completion_tokens直接写 0 有点敷衍。更准确的做法是拿model.chat前后的 history 内容长度反推但那需要 tokenizer 再跑一次多花时间。如果你有成本统计需求建议包一层stream_chat自己数。4.2bind_tools的转换与存储BaseChatModel在 0.3 版本有默认的bind_tools实现但它走的是 OpenAI schema然后指望模型自己在_generate里读kwargs[tools]。我选择覆盖掉逻辑更直白def bind_tools(self, tools, **kwargs): formatted [convert_to_openai_tool(t) for t in tools] return self.bind(glm_toolsformatted, **kwargs)convert_to_openai_tool吃三种输入BaseTool实例、Pydantic 模型、TypedDict。输出就是 ChatGLM3 认识的那个{type: function, function: {...}}结构——不错两边在这件事上难得达成了一致。返回值用self.bind(...)而不是直接return self。bind会产生一个RunnableBinding把glm_tools挂在上面后续invoke→_generate的**kwargs里就能拿到。如果返回self并改self.glm_tools并发场景下会互相覆盖这个坑我踩过一次排查了两个小时。4.3 自己造的tool_call_id必须前后一致ChatGLM3 不产出调用 ID但 LangChain 要。所以只能自己造import uuid call_id fcall_{uuid.uuid4().hex[:12]}格式上最好模仿 OpenAI 的call_前缀有些 Agent 实现会对 ID 做正则校验虽然少见但确实存在。关键在于一致性。模型这一轮产出tool_calls[{id: call_aaa}]下一轮ToolMessage就必须带tool_call_idcall_aaa。LangChain 的ToolNode会自动把 ID 传下去所以只要你在_to_ai_message里造了 ID 并且没在别的地方重新生成链路就是通的。有一种情况会断模型连续两轮产出结构完全相同的工具调用你用确定性哈希比如拿工具名参数算 md5当 ID两轮的 ID 就撞了。LangChain 内部用 ID 做去重撞了之后第二条ToolMessage会被丢掉。所以别用哈希用uuid4()。4.4_to_ai_message把 dict 和 str 两种情况都兜住这是整个适配层的收口写不好前面全白搭。def _to_ai_message(self, response, new_history) - AIMessage: raw_item new_history[-1] if new_history else None if isinstance(response, dict): tool_calls [{ name: response.get(name, ), args: _safe_args(response.get(parameters, {})), id: fcall_{uuid.uuid4().hex[:12]}, }] return AIMessage( content, tool_callstool_calls, response_metadata{glm_history_item: raw_item}, ) return AIMessage( contentstr(response), response_metadata{glm_history_item: raw_item}, )如果模型一次吐了多个工具调用process_response里是按|assistant|分段循环的理论上支持response只会保留最后一段。要拿全得从new_history里把所有metadata非空的 assistant 项捞出来tool_items [ h for h in new_history if h.get(role) assistant and (h.get(metadata) or ).strip() ]这个细节我在第三天才发现当时一直困惑为什么模型明明说了要查两个城市结果只查了一个。翻源码才明白是返回值覆盖。5. 工具 schema 从 LangChain 搬过去哪些地方会翻车5.1 复杂 schema 会吃掉大量 contextconvert_to_openai_tool对嵌套的 Pydantic 模型会生成$defs和$ref引用{ type: object, properties: { filter: {$ref: #/$defs/Filter} }, $defs: { Filter: { type: object, properties: {start: {type: string}, end: {type: string}} } } }模型不是 JSON Schema 解释器它看到$ref就是一堆看不懂的符号。实测下来带$ref的工具调用准确率会明显掉模型经常把filter参数整个略过。处理方式有两种一是在bind_tools之前把 Pydantic 模型拍平二是干脆只用 TypedDict 定义工具参数。我选后者工具参数的复杂度没必要那么高。5.2Optional和anyOf是重灾区class
阅读完成 · 觉得有帮助?