本地大模型第一次跑通的时候,人通常是兴奋的,但兴奋劲儿过去得也快。命令行里吐出来一坨没有换行、没有层级的纯文本,模型到底回了什么得拿眼睛一行行抠;你想让它查个数据、算个式子、读个本地文件,它不干,反而一本正经地编一个答案给你。这两件事——输出难看、能力受限——基本是所有人在 LangChain 加 Ollama 这条路上从“能跑”走向“能用”时最先撞上的两道墙。这篇就围绕 Ollama 美化输出与 Function-calling 这两个点展开,讲清楚终端里的回答怎么变得像人写的、模型怎么从“只会说”变成“能动手”,以及中间那些文档里不会写、只有真跑过才踩得到的坑。适合已经把本地模型拉起来、能跑通一次 invoke 的读者,也适合还在犹豫要不要上工具调用的人对照着看。
1. 先把问题拆开:这条链路上到底缺什么
1.1 裸输出的三个层次问题
很多人以为“输出难看”只是排版问题,其实分层看有三层,每层对应完全不同的解法。最表层是字符层,模型是一个 token 一个 token 往外蹦的,你不用流式就只能干等十几秒甚至几十秒,屏幕上一动不动,用户以为程序卡死了。中间层是格式层,模型的回答里带 Markdown 语法,###、**加粗**、- 列表、表格竖线全都原样打印,终端不认这些标记,读起来比纯文本还乱。最底层是结构层,当你需要程序接着往下处理这段回答时,人看的排版毫无意义,你要的是一个能直接json.loads()的对象,或者一个能塞进if判断的字段。
这三层问题的解法完全不同:字符层靠stream接口配合逐块打印,格式层靠终端渲染库把 Markdown 转成带颜色和缩进的字符,结构层靠输出解析器配合模型的格式遵循能力。把这三件事混在一起谈,就会出现“我加了渲染库但模型还是乱说”这种错位——渲染库只管画,管不了模型说什么。
提醒:格式层和结构层是互斥的两种目标。你要给人看就渲染 Markdown,你要给程序用就约束 JSON,别指望一次输出既漂亮又能被解析,那只会两头不讨好。
我一般的做法是明确分成两条链路:对话链路走流式加渲染,数据链路走结构化解析,两条链路用不同的提示词、不同的模型参数,甚至可以让结构化那条用温度 0、对话那条用温度 0.7。
1.2 为什么选 Ollama 本地跑而不是调云端 API
这个问题每次聊都会有人问。本地跑的理由其实很朴素:一是调试成本低,你改一句提示词就要重跑一次,走云端一天下来额度和钱都烧得心疼,本地的随便跑;二是数据不出机器,做内部工具或者处理私密文本时这是硬需求;三是延迟可控,局域网里没有网络抖动的干扰,工具调用的多轮往返不会因为某一次请求超时而整个崩掉。代价也很明确:模型能力天花板低一截,尤其是工具调用的稳定性,7B 级别和云端大模型差得不是一点半点。所以我的判断标准是——原型阶段、内部工具、对准确率容忍度高的场景,本地跑完全够用;要上生产、要处理复杂多步推理,本地模型只适合做预处理和分流。这一点想清楚了,后面所有的参数调优和兜底设计才有意义。
1.3 整体数据流:一次带工具的对话经历了什么
把链路摊开看会清楚很多。用户在终端敲一句话,LangChain 把它包成HumanMessage,连同系统提示、历史消息一起发给 Ollama 的 HTTP 接口。如果这次调用绑定了工具,请求体里会多一个tools字段,里面是一份 JSON Schema 描述,告诉模型“有这么几个函数、每个函数要什么参数”。模型读完这些,决定是自己直接回答,还是吐出一个“我想调用某个函数”的结构化请求。
关键在于——模型到此为止了,它不执行任何东西。它只是输出了一个意图,AIMessage里的tool_calls字段带着函数名和参数。真正干活的是你的 Python 代码:拿到函数名去工具表里查,把参数喂进去执行,把结果包成ToolMessage追加回消息列表,再发起第二次模型调用。这一次模型看到工具的真实返回值,才会组织成人话回复。整个流程至少两次模型往返,如果工具链有依赖关系,往返次数还会往上加。理解了这一点,你就明白为什么工具调用的延迟是普通对话的两三倍,也就明白为什么要把最大轮次卡死。
2. 环境和模型准备:版本、量化与参数
2.1 依赖装法与版本咬合
LangChain 这两年拆包拆得比较碎,装错包是最常见的第一个坑。核心要装的是三个:langchain-core提供消息、工具、解析器这些基础抽象;langchain-ollama是官方维护的 Ollama 集成包,负责把 HTTP 调用包成 LangChain 的接口;langchain本体主要提供 Agent 编排那套东西,如果你只做基础调用其实可以不装。以前很多人习惯用langchain-community里的Ollama类,那个已经处于维护状态,新代码建议直接用ChatOllama。
pip install -U langchain-core langchain-ollama rich pydantic版本上我踩过一次印象很深的坑:langchain-core升级到 0.3 之后,bind_tools的返回类型和tool_calls的字段结构都有微调,如果langchain-ollama还是旧版,会出现工具调用被识别成普通文本的情况——模型明明吐了正确的 JSON,但ai_msg.tool_calls就是空列表。所以这两个包要一起升,别单独动其中一个。装完之后跑一句pip show langchain-core langchain-ollama对一下版本,确认 ollama 包的依赖声明里要求的 core 版本和你实际装的一致,这一步花三十秒,能省掉两小时的迷惑。
另外rich是终端渲染用的,属于可选依赖,但装了之后体验差距很大,后面会详细说。pydantic现在基本是必装,LangChain 内部大量依赖它做参数校验,你写结构化输出也绕不开。
2.2 模型怎么挑:参数量、量化等级与工具能力
模型选型直接决定了这篇的两个主题能不能顺利做下去。先说结论:工具调用必须要 7B 起步,8B 到 14B 是甜点区,1.5B 到 3B 的小模型在这件事上基本不可用。不是完全不能用,而是它会频繁出现“该调不调”“调错函数”“参数编造”三种毛病,调起来的痛苦远超省下来的那点显存。
量化等级的选择是个权衡。Q4_K_M 是通用推荐,7B 模型落在 4.5GB 左右,质量损失在大多数任务上感知不到;Q5 和 Q6 质量更好但显存占用上去得快;Q8 基本接近原始精度但显存翻倍,除非你有 24G 以上的卡,否则没必要。我实测下来 7B 模型在 Q4_K_M 下做工具调用,函数名识别准确率已经够用,真正的瓶颈在参数抽取,比如把一个中文城市名正确填进city字段——这块 Q5 会比 Q4 稳一些,如果你的工具有强类型参数,值得往上提一档。
| 模型规模 | 量化 | 显存占用(约) | 工具调用表现 | 适用场景 |
|---|---|---|---|---|
| 1.5B-3B | Q4_K_M | 1.5-2.5GB | 经常漏调、参数错 | 纯文本补全、分类 |
| 7B-8B | Q4_K_M | 4.5-5.5GB | 单工具稳定,多工具偶错 | 大多数本地工具原型 |
| 7B-8B | Q5_K_M | 5.5-6.5GB | 参数抽取更稳 | 强类型参数场景 |
| 14B | Q4_K_M | 8.5-10GB | 多工具路由基本可用 | 多步工具链 |
| 32B | Q4_K_M | 19-21GB | 接近云端小模型 | 复杂编排 |
模型族的选择上,目前对工具调用支持比较完整的是各家的 instruct 版本,基础版(base)没有经过对话和工具训练的千万别用。拉模型的时候记得确认 tag 里带的是 instruct 或 chat 后缀,或者干脆用默认对话版本。
ollama pull qwen2.5:7b ollama list拉完之后先做个基线测试,别急着写代码:ollama run qwen2.5:7b "你好"看能不能正常回,再试试中文,确认模型本身没问题再往下走。
2.3 关键参数:num_ctx、num_predict、temperature 的取值逻辑
Ollama 有几个参数默认值非常坑,不显式设置会直接导致你怀疑人生。
num_ctx是上下文窗口长度,Ollama 的默认值长期是 2048。这意味着你把一份三千字的文档丢进去,模型可能只看到了前两千 token,后面的全被截断,然后它基于残存的信息一本正经地胡说。这个问题在工具调用场景下更隐蔽——工具返回的结果往往很长,加上历史消息,很容易撑爆两千的窗口,表现就是“模型好像忘了刚才调过什么”。我的做法是统一设成 8192 起步,长文档场景直接上 16384 或者 32768。
num_ctx和显存的关系要算一下。KV Cache 的占用大致是:
KV Cache ≈ 2 × n_layers × n_kv_heads × head_dim × seq_len × 精度字节数以 7B 模型、32 层、GQA 下 8 个 KV 头、head_dim 128、fp16(2 字节)为例,序列长度 8192 时:
2 × 32 × 8 × 128 × 8192 × 2 ≈ 1.07 GB序列长度翻到 32768 就是 4.3GB 左右。这个数值加上模型权重(4.5GB)才是实际显存占用。所以 8GB 显存的卡跑 7B 模型,num_ctx设到 16384 就已经很紧张了,再往上容易 OOM 或者被系统换页拖到龟速。这个账最好在动手前算一遍,别等跑了十分钟发现卡死才回头查。
num_predict是单次生成的最大 token 数,默认是 -1(不限制)。不限制的后果是模型偶尔陷入重复循环,一直吐同一句话直到把上下文占满才停。设个 2048 或者 4096 作为硬上限,既能装下正常回答,又能兜住失控的情况。
temperature在两条链路上取值完全不同。对话和创意类任务 0.6 到 0.8 比较自然;工具调用和结构化输出必须压到 0 到 0.2,因为这时候你要的是确定性,模型在高温下更倾向于“自由发挥”,参数填错的概率明显上升。我一般会给工具调用单独建一个低温度的实例,跟对话实例分开管。
from langchain_ollama import ChatOllama chat_llm = ChatOllama( model="qwen2.5:7b", temperature=0.7, num_ctx=8192, num_predict=2048, ) strict_llm = ChatOllama( model="qwen2.5:7b", temperature=0.0, num_ctx=16384, num_predict=1024, )还有一个keep_alive参数值得一提,默认模型在闲置 5 分钟后会从显存卸载,下次调用又要重新加载,那几秒到十几秒的等待很影响体验。开发阶段可以设成keep_alive="30m"让模型常驻,做完再改回去。
3. 美化输出:从字符流到可读文本
3.1 流式输出的正确打开方式
流式是所有美化工作的前提。不流式的话,用户在终端前面等二十秒看不到任何反馈,体验是灾难性的;而且大模型的输出本身是逐个 token 生成的,invoke只是帮你把这些 token 攒完再返回,并没有节省任何时间。
LangChain 里ChatOllama提供了stream和astream两个方法,返回的是AIMessageChunk的迭代器。最朴素的写法就是遍历打印:
for chunk in chat_llm.stream("用三句话解释什么是向量数据库"): print(chunk.content, end="", flush=True)这里flush=True是必须的,Python 的标准输出有缓冲,不加这个参数你会看到内容一段一段地蹦出来,而不是平滑地流。end=""也是必须的,否则每个 chunk 后面都跟着换行,输出会变成竖排。
但这种方式有个隐藏问题:chunk.content在某些模型上不一定是纯字符串,可能是内容块列表(特别是带多模态或者推理标签的模型),直接print会打出一堆字典。稳妥的写法是先判断类型:
for chunk in chat_llm.stream("..."): text = chunk.content if isinstance(chunk.content, str) else "" if text: print(text, end="", flush=True)另外流式场景下要知道什么时候结束。迭代器自然结束就是生成完毕,但如果模型因为num_predict上限被截断,你是感知不到的,用户会看到一个说到一半的句子。可以在循环结束后检查一下累计输出的长度,接近上限时给个提示,或者干脆把上限调大。
3.2 终端里的 Markdown 渲染
拿到流式文本之后,直接打印就是原样的 Markdown 源码,**和###全都露在外面。这时候上rich。它的Markdown类能把文本渲染成带 ANSI 颜色、带缩进、带项目符号的终端输出,代码块还会自动加背景色和语法高亮,观感提升非常明显。
难点在于流式和渲染的冲突:rich的Markdown是一次性渲染整段文本的,你不能每来一个 token 就重新渲染一遍,那样既慢又会闪屏。合理的做法是用Live上下文配合一个刷新节流——累积字符到一个阈值(比如每 80 个字符或者每 0.1 秒)才重新渲染一次。
import time from rich.console import Console from rich.markdown import Markdown from rich.live import Live console = Console() def render_stream(llm, prompt, min_interval=0.08, min_chars=60): buffer = "" last_render = 0.0 last_len = 0 with Live(console=console, refresh_per_second=12) as live: for chunk in llm.stream(prompt): if not isinstance(chunk.content, str): continue buffer += chunk.content now = time.time() if now - last_render > min_interval and len(buffer) - last_len >= min_chars: live.update(Markdown(buffer)) last_render = now last_len = len(buffer) live.update(Markdown(buffer))这段代码的核心是两个节流条件:时间间隔和字符增量。只按时间节流,模型输出慢的时候会频繁渲染短文本;只按字符节流,模型输出快的时候会积压。两个条件都满足才渲染,实测下来在 7B 模型上很稳。
注意事项:
Live内部的渲染会清屏重绘,如果你的程序在流式过程中还要打印日志,会把这部分内容冲掉。日志要么走console.print的同一个 console 对象,要么干脆在流式结束后再打。
还有一种情况要注意:模型输出到一半,Markdown 语法是不完整的,比如只吐了**加粗还没吐结尾的**,这时候rich会把它当成普通文本渲染,等后续字符到了再重新解析才恢复。这是正常现象,不是 bug,用户看到的效果是加粗样式延迟半秒生效,可以接受。
3.3 结构化输出:让模型直接吐对象
渲染是给人看的,解析是给程序用的。LangChain 里结构化输出有三条路,难度和稳定性递增。
第一条是JsonOutputParser,最轻量,本质上是把“请输出 JSON”这句话拼进提示词,然后把模型返回的文本拿去json.loads。它支持流式部分解析(partial=True时可以在 JSON 还没闭合的时候逐步吐出字段),适合做那种边生成边填表单的交互。缺点是模型经常不听话,前面加一句“好的,以下是结果:”,后面加一句“希望对你有帮助”,JSON 前后一堆废话,解析直接失败。
第二条是PydanticOutputParser,用 Pydantic 模型定义 schema,解析器会自动生成format_instructions塞进提示词,并在解析时做类型校验和字段补全。这条路的好处是强类型,模型少给字段或者给错类型会直接在解析阶段暴露,而不是等到业务逻辑里才炸。
from pydantic import BaseModel, Field from langchain_core.output_parsers import PydanticOutputParser from langchain_core.prompts import ChatPromptTemplate class BookInfo(BaseModel): title: str = Field(description="书名,不含副标题") author: str = Field(description="作者姓名") year: int = Field(description="首次出版年份,四位数字") tags: list[str] = Field(description="不超过三个分类标签") parser = PydanticOutputParser(pydantic_object=BookInfo) prompt = ChatPromptTemplate.from_messages([ ("system", "你是一个信息抽取助手。只输出 JSON,不要任何解释。\n{format_instructions}"), ("human", "{text}"), ]).partial(format_instructions=parser.get_format_instructions()) chain = prompt | strict_llm | parser result = chain.invoke({"text": "..."}) print(result.title, result.year)注意这里用的是strict_llm(温度 0)。用对话实例跑这个,成功率会掉一截。
第三条是模型原生的结构化输出,也就是利用 Ollama 的format参数传 JSON Schema。这条路最可靠,因为约束发生在解码阶段而不是提示词阶段——模型被强制只能生成符合 schema 的 token 序列,理论上不可能输出非法 JSON。ChatOllama支持传format参数:
structured_llm = chat_llm.with_structured_output(BookInfo, method="json_schema") result = structured_llm.invoke("...")不过要注意,json_schema这条路对模型和 Ollama 版本都有要求,老版本只支持format="json"(只保证是合法 JSON,不保证字段匹配)。如果跑起来报错说不支持,退回到PydanticOutputParser那条路,别在这上面死磕。
3.4 提示词层面的格式约束
工具再好也架不住提示词写得松。关于格式约束,我总结了几个实测有效的做法。
系统提示里把“只输出什么”和“不要输出什么”都写明,而不是只说前者。只写“请输出 JSON”,模型有相当概率给你包一层代码围栏;写上“不要使用 ```json 代码块包裹,不要添加任何前后说明文字”,遵守率会明显提升。
把 schema 放在消息的靠后位置。长上下文里模型对末尾内容的注意力更强,format_instructions如果塞在最前面,被忽略的概率更高。用ChatPromptTemplate的时候把它放在 human 消息之前、系统消息之后,效果比全塞系统消息里好。
给一两个示例。小模型对指令的泛化能力弱,但对示例的模仿能力强。一个正例一个反例,比写三段规则管用。
温度压到 0.1 以下。这一条前面提过,但在结构化输出上影响特别直接,值得再说一遍。
最后,无论提示词写得多好,解析层一定要有兜底。JsonOutputParser外面套一个 try/except,失败时用正则从文本里抠第一个{到最后一个}之间的内容再试一次,还失败就走重试或者降级返回。这个兜底看起来土,但在真实场景里能救回大概百分之十几的失败率。
4. Function-calling:把“会说”变成“会做”
4.1 底层机制:模型只负责表态,不负责执行
这个概念必须先讲透,不然后面所有的代码都是照抄。工具调用的本质是一次协议约定:你在请求里附上一份工具清单(名字、描述、参数的 JSON Schema),模型在生成时如果判断需要外部信息,就不输出普通文本,而是输出一段特殊结构的 token,内容大概是“我要调用 query_weather,参数是 city=示例市”。LangChain 的ChatOllama负责把这段特殊结构解析成AIMessage.tool_calls列表。
到此为止,模型的工作结束了。它没有执行任何代码,没有联网,没有读文件,它只是表达了意图。后面所有的事都是你的程序在做:查工具、执行、把结果塞回去、再问一次模型。这个“再问一次”很关键,很多人第一次写的时候忘了第二次调用,直接把工具返回值当最终答案打印出来,得到一坨 JSON。
理解这一点还有个好处:你会明白为什么模型会“编造工具调用”——它没有工具的真实返回,只能根据训练数据里的模式猜一个合理的调用。温度高了、描述模糊了、上下文里有过类似的调用,都可能触发这种幻觉。
4.2 工具定义的写法与描述技巧
@tool装饰器是最省事的写法,函数名、类型注解、docstring 三样东西会被自动转成 JSON Schema。
from langchain_core.tools import tool @tool def query_weather(city: str, unit: str = "celsius") -> str: """查询指定城市的当前天气。 Args: city: 城市名称,中文全称,例如“示例市”。 unit: 温度单位,只能是 celsius 或 fahrenheit。 """ # 这里换成真实的查询逻辑 return f"{city} 当前晴,22 度"大部分教程到这里就结束了,但真正的功夫在 docstring 上。docstring 是模型唯一能看到的函数说明,它决定了模型在什么情况下会想起调用这个函数。写得含糊,模型就不调;写得啰嗦,会挤占上下文;参数描述缺失,模型就会瞎填。
我踩过的几个具体问题:一是函数名用缩写,qw这种,模型完全不知道是干嘛的,改成query_weather立刻就正常了;二是 docstring 只写“查询天气”,没写适用场景,模型在问“明天要带伞吗”这种间接问题时不会想到调用它,补上“当用户询问天气状况、是否需要带伞、穿衣建议时使用”之后就对了;三是参数没写枚举范围,模型给unit填了个"摄氏度",加一句“只能是 celsius 或 fahrenheit”能解决大部分。
还有个坑是参数类型。str、int、float、bool这些基础类型没问题,list[str]也还行,但一旦上到list[dict]或者嵌套的 Pydantic 模型,小模型基本就懵了,要么字段名写错,要么结构整个歪掉。我的做法是把复杂参数拆成多个简单参数,让模型填平铺的键值对,进了函数内部再自己组装。
工具数量也要控制。实测下来 5 个以内模型路由得比较准,超过 8 个就开始出现“明明该调 A 却调了 B”的情况,工具之间的描述越相似,错得越厉害。工具多了就做分组,或者在系统提示里明确“优先使用某个工具”。
4.3 手写执行循环:完整可跑的骨架
LangChain 提供了create_tool_calling_agent这类封装,但我建议第一遍一定手写循环,写通了再考虑用封装。手写的过程能让你彻底搞明白消息是怎么流转的,出问题时也知道该看哪里。
from langchain_core.messages import HumanMessage, SystemMessage, ToolMessage tools = [query_weather] tool_map = {t.name: t for t in tools} llm_with_tools = strict_llm.bind_tools(tools) def run_with_tools(question: str, max_rounds: int = 4) -> str: messages = [ SystemMessage(content="你是一个助手。需要外部数据时调用工具,不要凭记忆编造。"), HumanMessage(content=question), ] for _ in range(max_rounds): ai_msg = llm_with_tools.invoke(messages) messages.append(ai_msg) if not ai_msg.tool_calls: return ai_msg.content for call in ai_msg.tool_calls: fn = tool_map.get(call["name"]) if fn is None: messages.append(ToolMessage( content=f"错误:不存在名为 {call['name']} 的工具", tool_call_id=call["id"], )) continue try: result = fn.invoke(call) except Exception as exc: result = f"工具执行失败:{exc}" messages.append(ToolMessage( content=str(result), tool_call_id=call["id"], )) return "抱歉,工具调用轮次超限,未能完成任务。"这几十行里有几个点必须对齐,错一个就跑不通。
tool_call_id必须原样回填。每个tool_call都有一个id字段,你返回的ToolMessage必须带上同一个 id,模型才能把“结果”和“请求”对应起来。手动拼字符串、或者用旧的function_call格式,都会导致模型看不懂上下文。
工具执行一定要包 try/except。工具里可能有网络请求、文件读写、除零,任何异常直接抛出去都会让整个链断掉。捕获之后把错误信息作为工具结果返回给模型是更好的选择,模型看到“参数格式错误”往往能自己修正重试。
必须设最大轮次。模型有时候会陷入循环,反复调用同一个工具,尤其是工具总是返回空结果或者错误的时候。卡在 4 轮,超出就返回兜底文案,这是最省事的保护。
fn.invoke(call)这个写法是 LangChain 的便捷方式,它接受完整的 tool_call 字典,内部会自动取args。如果想更明确,也可以写成fn.invoke(call["args"]),效果一样。
系统提示里那句“不要凭记忆编造”不是摆设。加上之后模型在该调工具时的触发率明显上升,尤其是问实时数据(天气、股价、时间)这类问题时,不加这句它更倾向于直接编一个。
4.4 多工具路由与轮次控制
两个以上工具之后,问题从“调不调”变成“调哪个”。这时候工具描述之间的区分度就是生死线。假设你有一个search_web和一个search_local_docs,描述都写“搜索信息”,模型只能靠猜;把前者写成“搜索互联网上的实时公开信息,适合新闻、时事”,后者写成“搜索本地知识库文档,适合公司制度、产品手册”,路由准确率立刻不一样。
多工具场景下还有一个现实问题:串行执行慢。模型一次可能返回多个tool_calls,这些调用之间如果没有依赖关系,完全可以并发跑。用concurrent.futures包一层就行:
from concurrent.futures import ThreadPoolExecutor with ThreadPoolExecutor(max_workers=4) as pool: futures = {pool.submit(fn.invoke, call): call for call in ai_msg.tool_calls} for future in futures: call = futures[future] try: content = str(future.result()) except Exception as exc: content = f"工具执行失败:{exc}" messages.append(ToolMessage(content=content, tool_call_id=call["id"]))并发之后要注意消息顺序。虽然ToolMessage的对应关系靠 id 而不是顺序,但为了让模型容易读,最好按原始tool_calls的顺序追加。
轮次控制上,我一般设 3 到 4。一轮是一个“模型请求工具 + 工具返回结果”的完整周期。超过 4 轮还没收敛,八成是工具返回值有问题或者问题本身超出了工具能力,继续加轮次只是在烧算力。可以在达到上限时把中间的消息历史打出来看一眼,通常一眼就能看出卡在哪。
5. 常见问题与排查实录
5.1 工具死活不触发
这是最高频的问题,排查顺序建议按下面的表走,从便宜的检查开始。
| 现象 | 可能原因 | 验证方法 | 处理 |
|---|---|---|---|
tool_calls恒为空 | 模型不支持工具调用 | 换 7B+ instruct 模型试 | 换模型 |
| 该调不调,直接编答案 | docstring 太笼统 | 手动看 schema 内容 | 补场景描述 |
| 偶尔调偶尔不调 | 温度过高 | 把 temperature 设 0 再试 | 降到 0.2 以下 |
| 多个工具时调错 | 描述区分度不够 | 对比两个工具描述 | 写明各自适用场景 |
| 调用了不存在的工具名 | 模型幻觉 | 打印tool_calls看名字 | 循环里做名字校验 |
排第一的永远是模型本身。我用同一个提示词在 3B 和 7B 上对比过,3B 在该调工具的场景下大概一半时候会直接编答案,7B 能到九成以上。如果你的模型是小参数量的,先别调提示词,换模型。
第二常见的是bind_tools之后忘了真的用绑定后的实例。写成llm.invoke(...)而不是llm_with_tools.invoke(...),工具清单压根没发出去,模型当然不会调。这个错误很低级但真的很常见,尤其是代码重构之后。
第三个容易被忽略的是历史消息污染。如果前面的对话里模型输出过一段假装调用的文本,后面的轮次会受这个模式影响,反复输出类似内容而不产生真正的tool_calls。排查时把完整的messages打出来看,发现问题就截断历史重开一轮。
5.2 JSON 解析失败的六种现场
结构化输出失败的花样比想象中多,我按出现频率列一下。
第一种,前后带解释文字。模型说“好的,这是提取结果:{...},希望对你有帮助”。对策是在提示词里双向约束,还是不行就在解析前用正则抠出第一个{和最后一个}之间的部分。
第二种,被代码围栏包住。json 开头结尾。解析前做一次 strip,判断是否以围栏开头,是就去掉首尾两行再解析。
第三种,用了中文标点。"title":“某某”里的冒号引号全角,json.loads直接报错。这个在中文场景下特别常见,可以在解析前做一次标点替换,把全角的:"",换成半角。这个替换有风险,如果字段值本身包含全角标点会被误伤,所以只对结构字符做替换,而且要放在兜底流程里,不能作为主路径。
第四种,字段名对不上。schema 里是book_title,模型给了title。PydanticOutputParser会报缺失字段,JsonOutputParser则静默给你一个缺键的字典。对策是字段名尽量短、语义明显,别用下划线复合词。
第五种,类型漂移。year定义成int,模型给了"2023年"。这个在带单位的字段上高频出现,解决方式是在Field的 description 里写死格式要求,并在 Pydantic 模型上加field_validator做容错转换。
第六种,JSON 截断。生成到一半撞上num_predict上限,括号没闭合。这个从报错信息上能看出来(Expecting ',' delimiter出现在末尾附近),把上限调大或者缩短输出要求就行。
实操心得:把每次解析失败时的原始文本落盘,攒二十条之后你会发现失败模式高度集中,通常两三个正则或者提示词调整就能覆盖九成。
5.3 流式与工具调用的兼容问题
流式和工具调用放一起会出问题,原因是tool_calls在流式返回里是分片拼的:函数的 id 在第一个 chunk,名字可能在第二个,参数是一点点拼起来的字符串。你在循环里直接读chunk.tool_calls,拿到的往往是半截数据。
正确的做法是先把所有 chunk 合并。LangChain 的AIMessageChunk支持+运算符,累加之后得到完整的消息:
full = None for chunk in llm_with_tools.stream(messages): if full is None: full = chunk else: full += chunk if isinstance(chunk.content, str) and chunk.content: print(chunk.content, end="", flush=True) if full and full.tool_calls: # 这里再做工具执行 ...另一种做法是用astream_events,它能区分内容事件和工具事件,on_chat_model_stream里拿文本,on_tool_start/on_tool_end里拿工具执行。这套机制更适合做进度展示,比如在终端里显示“正在查询天气……”这样的状态行,同时文本继续往外流。代价是 API 比较复杂,事件类型多,调试的时候容易看花眼,建议先用累加 chunk 的方式跑通,再考虑升级。
还有个细节:工具调用的中间轮次通常是没必要流式展示的,模型第一轮返回的只是一个“我要调工具”的空内容,流式打印出来什么都没有,用户看到的是停顿。可以在第一轮静默处理,只在最终回答那一轮流式输出。判断方式就是看full.tool_calls是否为空。
5.4 显存、速度与上下文
跑本地模型绕不开资源问题。常见的几个现象和对策:输出突然变慢十倍,通常是模型被换出了显存,检查ollama ps看看是不是有别的进程占着卡;回答到一半开始重复同一句话,是生成进入循环,降温度、设num_predict上限;长时间没响应直接超时,多半是num_ctx设太大加上长上下文导致 prompt 处理时间爆掉——7B 模型处理 32K 上下文的 prompt,光预填充就可能要几十秒,用户以为是卡死。
速度方面,影响最大的是模型大小和量化,其次是显存带宽。同一个 7B Q4 模型在不同机器上 token 生成速度能差三倍以上。如果只是做原型验证,不用纠结速度,先跑通再说;如果要给人用,考虑在应用层加个进度提示,哪怕只是三个点。
上下文管理上,长期对话要注意消息列表会一直增长,到后面每次请求都要把整段历史重发一遍,prompt 处理时间线性上升。我的做法是保留最近 N 轮完整消息,更早的做摘要压缩,或者干脆截断。工具调用的历史消息尤其占地方,因为工具返回值往往很长,可以在追加ToolMessage之前把结果截断到合理长度(比如 2000 字符),超出部分打个省略标记。
6. 落地时的一些取舍
写到这里,两个主题的链路都通了,最后聊几句取舍上的体会。
渲染和解析别混在一条链上。我见过有人在一个函数里既想流式打印 Markdown,又想同时解析出结构化数据,结果两个目标互相干扰,代码复杂度翻倍。分开做,对话走一套,数据抽取走另一套,各自用最适合的参数和提示词,维护起来清爽得多。
工具的粒度宁粗勿细。一开始很容易把每个小功能都做成一个工具,结果工具列表膨胀到十几个,模型路由准确率断崖下跌。更好的做法是把相关的操作合并成一个带action参数的函数,比如manage_file(action, path, content),让模型只决定“做什么类别的事”,细节参数在函数内部处理。
兜底逻辑要写在前面。工具执行失败怎么办、JSON 解析失败怎么办、轮次超限怎么办,这三个分支如果不提前设计,等到线上出问题再补,往往会引发更复杂的连锁反应。我的一般做法是每个分支都给一句明确的用户可读提示,同时在日志里落下完整上下文,方便事后复盘。
别迷信大参数。14B 比 7B 强,但不代表所有任务都值得上 14B。单工具调用这个场景,7B 已经足够稳,多花一倍显存换来的边际收益很小。真正需要往上堆参数的场景是多工具路由和复杂的多步推理,那才是规模的用武之地。
我个人在实际操作中的体会是,本地模型跑工具调用这件事,八成的效果来自三件事——模型选对、工具描述写清楚、参数压到低温度。剩下的两成才是框架封装和代码技巧。很多时候跑不通不是 LangChain 的问题,也不是 Ollama 的问题,回头看一眼 docstring 和工具清单,问题往往就在那儿。