如果你最近在C#项目里接了Ollama的ToolCall,大概率会有一个很直观的感受:这模型怎么这么“笨”。同一个需求,远端API版模型一遍就能跑通,放到本地Ollama上,要么死活不调用工具,要么调了之后参数丢三落四,甚至会一本正经地编一个不存在的函数名。这篇内容不是讲Ollama怎么安装,也不是贴官方文档示例,而是从C#接入方的角度,把ToolCall“笨”的前因后果拆开揉碎,讲清楚是协议没对齐、工具定义没喂饱,还是模型选型压根不对路。如果你正在做自然语言调用本地工具的Agent、智能客服、或者内部自动化调度Demo,这篇文章大概率能帮你省掉几个晚上的排查时间。
1. “笨”在哪:先用几个C#接入场景说清现象
1.1 官方Demo能跑,换到真实业务就崩
很多人的第一步都是从Ollama官方示例开始的:一个简单的get_weather工具,参数就city和date两个字符串,跑起来看起来一切正常,模型会返回tool_calls,C#端也能把参数解析出来。但一旦把工具换成真实业务里的东西,比如“查询订单并按状态筛选”“根据用户画像推荐内容”,模型就开始犯糊涂。
我在一个内部调度项目里就遇到过这种情况:工具定义大概有七八个,每个参数三到五个,嵌套结构两层。结果模型返回的arguments和我在代码里定义的JSON Schema完全对不上,该传string的传了数字,该传枚举值的传了拼音缩写。当时第一反应是“模型不够聪明”,后来才发现,问题的根源根本不在模型,而在调用方给模型喂的东西。
这类现象特别有欺骗性,因为Demo能跑会让人误以为链路是通的。实际上链路通只是说明HTTP通信没断,模型有没有真正理解你的工具协议,完全是另一回事。
1.2 四种典型“笨”法一览
我总结了一下,C#接入Ollama时最常见的“笨”有以下四种。先放在一张表里,后面逐一展开。
| 表现 | 具体场景 | 常见误判 |
|---|---|---|
| 该调不调 | 用户明确问“帮我查一下天气”,模型却直接回答“我无法获取天气信息” | 误以为是模型能力不行 |
| 调了但参数错乱 | 工具叫get_weather,返回的arguments却少了date,或者把“北京”写成“beijing” | 误以为是序列化问题 |
| 复读机式回答 | 模型在content里写了一长串JSON,而不是在tool_calls里结构化返回 | 误以为是工具定义格式不对 |
| 多工具串联混乱 | 先调了A工具,拿到结果后应该调B,结果模型把A的结果当成B的参数又调了一遍A | 误以为是状态管理有bug |
每一种“笨”都对应调用链路上不同的断点。我在后面章节会逐个拆。这里想先强调一个观点:Ollama的ToolCall不是一个开关,而是一套需要调用方主动配合的协议。模型本身只是一个文本生成器,它能不能“聪明”地调用工具,取决于你给它看到的世界够不够清晰。
2. 从调用链看根因:Ollama的ToolCall协议到底怎么工作
2.1 一条ToolCall请求的完整旅行线路
要理解“笨”从哪来,先得把一条ToolCall请求的一生走一遍。Ollama的接口路径是/api/chat,请求体里除了常规的model和messages,还有一个可选字段tools。这个tools字段就是你把函数列表“告诉”模型的地方。
C#端通常是这样构造请求的:
var payload = new { model = "qwen2.5:7b", messages = new[] { new { role = "user", content = "帮我查一下北京明天的天气" } }, tools = new[] { new { type = "function", function = new { name = "get_weather", description = "查询指定城市在指定日期的天气情况", parameters = new { type = "object", properties = new { city = new { type = "string", description = "城市名,如北京" }, date = new { type = "string", description = "日期,格式yyyy-MM-dd" } }, required = new[] { "city", "date" } } } } }, stream = false };模型拿到这段文本之后,会把它当成“提示词上下文”的一部分来处理。模型内部其实没有“调用工具”这个动作,它只是在预测下一个最合理的token序列。如果它判断当前用户问题需要外部数据,就会在返回的message里带上tool_calls数组,数组里的每一项包含函数名和参数对象。
C#端收到响应后,解析message.tool_calls,逐个执行真正的函数,再把执行结果以role = "tool"的消息追加进messages数组,发第二轮请求。这一步非常关键:工具结果必须以tool角色回传,模型才能基于结果继续生成最终回答。这个循环可以多轮,直到模型不再返回tool_calls为止。
2.2 三个容易翻车的协议细节
很多人觉得ToolCall“笨”,是因为把Ollama的返回格式和OpenAI的格式混着用。三者之间有几个明显的差异,C#端不注意就会出事故。
第一,Ollama返回的arguments是JSON对象,不是JSON字符串。OpenAI的返回里function.arguments是字符串,需要二次解析;Ollama直接给你对象。有人用OpenAI风格的SDK去连Ollama,拿到字符串后硬套解析逻辑,反而把好端端的对象搞坏了。
第二,Ollama没有标准的finish_reason字段,或者说不像OpenAI那样稳定地带tool_calls这个终止原因。判断模型这一轮是不是要调用工具,最可靠的方式是直接检查message.tool_calls数组存在与否、长度是否大于零,而不是看finish_reason。
第三,Ollama对模型返回的内容几乎不做后处理。模型说参数里有个"city": "beijing",Ollama就原样透传给你。它不会帮你修正,不会对照你的JSON Schema做校验。所以C#端必须自己做容错,否则再小的格式偏差都会变成运行时异常。
这三点叠加在一起,结论就是:Ollama把“理解工具定义”和“结构化生成参数”的压力完全转移给了客户端和模型本身。你用的本地模型越弱,客户端给的提示越粗糙,ToolCall就越笨。
3. 五个最常让ToolCall变“笨”的C#侧写法
3.1 根因一:工具Schema“营养不良”
这是我在项目里排查出最多的原因,没有之一。很多人写工具定义时只给name和parameters,description要么不写,要么写一句“查询天气”。模型不知道你这个函数是干什么的、参数取什么值、格式是什么,它只能靠猜。
我之前在一个Demo里写过这样的工具定义:
new { type = "function", function = new { name = "get_weather", description = "查询天气", parameters = new { type = "object", properties = new { city = new { type = "string" }, date = new { type = "string" } } } } }模型看到这种定义,根本不知道city该填“北京”还是“Beijing”,date该填“明天”还是“2025-06-01”。它能给你返回tool_calls就已经算给面子了,参数大概率是错的。
后来我把定义改成了这样:
new { type = "function", function = new { name = "get_weather", description = "根据城市和日期查询天气情况,用于帮助用户安排出行、运动等决策", parameters = new { type = "object", properties = new { city = new { type = "string", description = "城市名称,使用中文全称,例如:北京、上海、广州" }, date = new { type = "string", description = "查询日期,格式为yyyy-MM-dd,例如2025-06-01,不要使用相对日期" } }, required = new[] { "city", "date" } } } }看起来只是多写了几行描述,效果天差地别。模型是文本生成模型,你给它的描述越像一段真实存在的代码注释,它就更能理解参数约束。这个经验我后面还会再强调。
3.2 根因二:消息历史里少了关键角色
第二个高频坑,是消息历史的角色顺序不对。Ollama要求工具调用过程遵循一个固定节奏:user提问,assistant返回tool_calls,客户端执行工具,然后以role=tool回传结果,最后assistant再基于结果生成最终回答。
有的人图省事,拿到工具结果后直接把它拼成一句普通文本塞进user消息,或者干脆在客户端本地拼好答案,不再发一轮请求。结果就是模型什么都没学到,下一轮对话依旧瞎猜。
正确的多轮消息序列应该是这样的:
user: 帮我查一下北京明天的天气 assistant: (tool_calls = get_weather(city=北京, date=2025-06-01)) tool: 晴,25度,微风 assistant: 北京明天天气晴朗,气温25度,适合外出如果你在C#端实现的是List<object>拼接消息,一定要保证tool角色的消息出现在assistant的tool_calls之后,而不是被插到别的位置。Ollama对消息顺序很敏感,顺序错了,模型就分不清哪些内容是工具返回的,哪些是你替它编的。
3.3 根因三:System.Text.Json序列化选项没对齐
这一点是C#特有的坑,别的语言里相对少见。System.Text.Json默认序列化枚举时输出的是数字,如果你在工具定义和请求体里混用了枚举类型,模型看到的可能不是“晴/多云/雨”而是“0/1/2”。
举个例子:
public enum WeatherCondition { Sunny, Cloudy, Rainy } var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, Converters = { new JsonStringEnumConverter() } };如果你忘了加JsonStringEnumConverter,序列化后的JSON里就是"condition": 0,模型对这种数字枚举非常不敏感,很容易返回一个不存在的值。更麻烦的是属性命名策略,默认PascalCase会让CityName变成CityName而不是cityName,而你在描述里写的是“城市名”,模型可能就直接拿属性名当参数名返回。
所以C#端要在入口统一配置好JsonSerializerOptions,并且在生成工具Schema时用同一套选项。我建议把options定义成静态单例,避免各处各写一份、最后发现序列化结果不一致。
3.4 根因四:模型选型“小马拉大车”
Ollama能跑的模型很多,但不是每个模型都擅长工具调用。在本地量化场景里,我实测下来的经验是:参数越小的模型,工具调用越容易翻车,而且翻车方式特别统一——漏参数、编参数、拒绝调用。
我用过比较典型的几个模型,结果如下:
| 模型 | 工具调用稳定性 | 踩坑点 | 适用建议 |
|---|---|---|---|
| llama3.1:8b | 一般 | 参数一多就漏,嵌套对象经常化成平铺 | 只适合单工具、少参数场景 |
| qwen2.5:7b | 较好 | 中文描述友好,偶发枚举值偏差 | 常规业务工具推荐 |
| qwen2.5:14b | 好 | 显存占用高,首次加载慢 | 复杂多工具场景推荐 |
| deepseek-r1:7b | 一般 | 推理过程会夹带工具调用,需要过滤 | 需要先清理reasoning |
| phi4:14b | 较好 | 英文描述效果更稳 | 英文工具名/参数名更友好 |
如果你的业务工具很多、参数复杂,不要指望一个3B的模型能稳定输出几十个字段的arguments。这不是C#代码能补齐的,是模型本身的生成能力上限。遇到这种需求,要么换大一点的模型,要么把工具拆细、接口单薄化,让每个工具只管一件事。
3.5 根因五:流式SSE解析把tool_calls搞丢了
最后一个问题比较隐蔽,只在开启stream = true时出现。Ollama流式返回tool_calls时,不是一次性把整个数组吐出来的,而是像打字机一样分段输出。你如果每收到一帧就当作完整结果消费,就会发现自己拿到的arguments残缺不全。
C#里正确做法是先把流式帧缓存起来,在请求结束的那个done帧到来之前,将每一帧的message.tool_calls增量累积,最后再统一解析。简单示意:
var toolCalls = new List<JsonNode>(); await foreach (var chunk in streamChunks) { var delta = chunk?["message"]?["tool_calls"]; if (delta is not null) { toolCalls.AddRange(delta.AsArray()); } } // 流结束后再整体处理 toolCalls如果你用的是System.Net.Http.Json里的GetFromJsonAsync做流式读取,一定要自己处理SSE格式,不要省掉累积那一步。这个坑排查起来特别耗时间,日志里看起来像模型“时灵时不灵”,分次返回的字段有时全有时缺,其实只是解析方式不对。
4. 一次“用自然语言查天气并推荐晨跑公园”的排查实录
4.1 第一轮跑出来的“笨”日志
我从头到尾走一遍当时排查的过程,你可以对比着自己的项目看。场景很简单:本地Agent接Ollama,用户输入的句子是“帮我查一下北京明天早上适不适合跑步,如果适合就推荐一个附近的公园”。
我预先定义了两个工具:get_weather查询天气,get_nearby_parks根据城市和天气推荐公园。第一次跑出来的结果是这样的,模型压根没走工具,直接在content里输出了一堆JSON:
{ "action": "get_weather", "params": { "city": "北京", "date": "明天" } }看起来它“知道”该调用工具,但它把调用动作表达成了文本,而不是结构化返回tool_calls。这种状况最迷惑人,因为你会在日志里看到工具调用的名字和参数,以为只是格式不对,改改解析就行。但实际上模型完全没有进入工具调用模式。
4.2 用curl把Ollama的原始返回拉出来看
排查的第一步,我建议所有C#侧的问题都先用命令行工具复现,把C#代码暂时扔到一边。我用curl构造了一个最小请求,把同样的messages和tools直接打到Ollama的/api/chat接口上,拿到最原始的返回JSON。
curl -X POST http://localhost:11434/api/chat -d '{ "model": "llama3.1:8b", "messages": [{"role": "user", "content": "帮我查一下北京明天早上适不适合跑步,如果适合就推荐一个附近的公园"}], "tools": [{ ... }], "stream": false }'返回结果显示,模型根本没有进入tool_calls分支,而是把调用动作写进了content。到这一步,我基本确定问题不在C#解析层,而在模型对工具定义的理解层。然后我换了qwen2.5:7b再跑同样的请求,结果立刻就能输出正常的tool_calls结构。这说明模型选型差异是非常关键的一个变量。
4.3 工具定义瘦身后的前后对比
接下来我又测试了同一模型下工具定义的影响。把get_weather的description从“查询天气”改成包含取值约束的完整描述,把get_nearby_parks的参数从两层嵌套对象改成了扁平字符串和枚举值,效果立竿见影。
修改后的response长这样:
{ "message": { "role": "assistant", "content": "", "tool_calls": [ { "function": { "name": "get_weather", "arguments": { "city": "北京", "date": "2025-06-01" } } } ] } }这个结果跟预期完全一致。你可以看到,同一个模型,在工具定义“营养不良”时糊弄你,在定义清晰后就能准确输出。后来我在多个模型上重复测试,凡是出现“参数错乱”“该调不调”的,八成以上可以通过改造工具定义解决,而不是换模型。
这个排查链路的核心思路是:先绕开C#代码直接看Ollama原始返回,再把变量拆开单独控制。很多问题混在一起时看着像玄学,拆开之后就发现只是某个环节没对齐。
5. 让ToolCall变“靠谱”的落地配置与代码模板
5.1 工具定义写作规范
这一节给出一个可以直接抄走的工具定义模板,你照着这个结构写,不敢说一定能保证100%稳定,但能把大多数“笨”的问题挡在门外。
规范提炼下来是四点:
- 函数名用英文驼峰,描述用中文写清楚“什么时候该调用、调用后能解决什么问题”
- 每个参数都要有description,里面写清楚取值范围、格式、单位、不要用什么值
- 能用枚举就不用自由字符串,枚举值也要在description里列全
- 避免过深的嵌套结构,能拍平就拍平
下面是一个标准模板:
new { type = "function", function = new { name = "get_nearby_parks", description = "根据城市和天气条件推荐适合晨跑的公园,只有用户需要户外运动推荐时才调用", parameters = new { type = "object", properties = new { city = new { type = "string", description = "城市名称,使用中文全称,例如:北京、上海" }, weather = new { type = "string", description = "天气状况,取值:晴朗、多云、小雨、大雨" } }, required = new[] { "city", "weather" } } } }注意description里我写了“只有用户需要户外运动推荐时才调用”,这就是在告诉模型触发条件,能显著减少“不该调也调”的情况。
5.2 C#端调用循环的正确骨架
工具调用的核心循环可以封装成一个通用方法,五次到十次是合理上限。下面是适合直接复用的C#骨架代码,注意消息顺序和tool_calls的累积判断。
public static async Task RunToolLoopAsync( HttpClient client, string model, List<object> messages, IReadOnlyList<object> tools, int maxTurns = 5) { var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, Converters = { new JsonStringEnumConverter() } }; for (var turn = 0; turn < maxTurns; turn++) { var payload = new { model, messages, tools, stream = false }; using var resp = await client.PostAsJsonAsync("/api/chat", payload, options); var json = await resp.Content.ReadFromJsonAsync<JsonNode>(options); var msg = json?["message"]; var toolCalls = msg?["tool_calls"]?.AsArray(); if (toolCalls is null || toolCalls.Count == 0) { Console.WriteLine(msg?["content"]?.GetValue<string>()); return; } messages.Add(new { role = "assistant", content = msg?["content"]?.GetValue<string>() ?? "" }); foreach (var call in toolCalls) { var name = call?["function"]?["name"]?.GetValue<string>(); var argsNode = call?["function"]?["arguments"]; if (argsNode is null) continue; var argsText = argsNode.GetValueKind() == JsonValueKind.String ? argsNode.GetValue<string>() : argsNode.ToJsonString(options); var result = await ExecuteToolAsync(name, argsText); messages.Add(new { role = "tool", content = result }); } } throw new InvalidOperationException("工具调用超出最大轮次"); }这段代码里有两个细节值得说明。一个是argsNode同时兼容了“字符串”和“对象”两种形态,因为不同模型返回的arguments类型不稳定,这点容错必须留。另一个是assistant消息必须在执行工具前加入messages,这能让模型知道当前轮到它讲话了,符合上一节说的角色顺序。
5.3 模型、上下文与超时参数推荐
最后说运行参数。Ollama默认上下文窗口不一定够用,工具定义本身很占token,模型要理解十几个工具的描述,需要足够的窗口。我个人的经验值是至少把OLLAMA_NUM_CTX设到8192,如果工具多,直接上16384。
推荐配置大概是这样:
- 模型优先选qwen2.5:14b或同级别,7b也可以但工具数量控制在五个以内
- 上下文窗口不低于8192
- 工具数量控制在十个以内,太多的话让模型先做意图分类,再挂对应的工具子集
- 单轮ToolCall循环上限五轮,超时给到60秒以上,本地模型推理慢,别急着报错
- 如果工具调用失败,不要静默吞掉,把原始返回JSON打到日志里,方便定位是协议问题还是模型问题
我个人的习惯是,凡是工具调用相关需求,先把工具定义和消息顺序写对,再考虑换模型。模型永远是用力的下限,你给它的协议越干净,它才越稳定。做C#接入本身就是把API、序列化、消息编排这些后端能力变成模型的“脚手架”,脚手架搭得越扎实,模型“笨”的概率就越低。