C#教程

AI时代C#技术:用Semantic Kernel接本地Ollama上带“思考过程”的模型(Qwen3.5这类),kernel.InvokeAsync直接返回空结果,这个到底是框架的抽象不支持reasoning content,还是用户配置问题?

👤 为我痴狂 👁 51 阅读 ❤ 0 点赞 ➦ 0 分享 📅 2026-10-29
首页› 理学› C#教程› 正文
🌿 Semantic Kernel · Ollama · Reasoning Content
AI时代C#技术:Semantic Kernel接本地Ollama上带"思考过程"的模型,kernel.InvokeAsync为何返回空结果?

——从抽象层语义丢失到推理内容治理的工程化拆解

摘要

在 .NET 生态中,Semantic Kernel(下称 SK)配合本地 Ollama 部署带"思考过程"的推理模型(如 Qwen3 系列、DeepSeek-R1 蒸馏版等),是当前私有化 AI 应用的高频组合。但不少开发者遇到同一现象:模型在 Ollama 侧或原生 HTTP 调用中能正常输出 reasoning_content / thinking 字段,而经 kernel.InvokeAsync 调用后,返回的 FunctionResult 却为空或只剩空字符串。

本文的核心判断是:这既不是单纯的"框架抽象不支持",也不是纯粹的"用户配置错误",而是三层语义契约错位叠加的结果——Ollama 的响应字段命名、OpenAI 兼容端点的内容映射、SK 的 ChatMessageContent 模型三者对"推理内容"的承载能力不一致。文章沿"现象复现 → 协议抓包 → 抽象层源码定位 → 版本矩阵验证 → 工程化修复 → 治理与展望"这条主线展开,给出可操作的诊断清单与代码路径。

一、问题的提出:一个"空结果"背后的三重契约

先把现象精确化。典型场景是:开发者在本地用 ollama run qwen3 或 curl 直接请求 /api/chat,能看到响应 JSON 里同时存在 message.content 与 message.thinking(或旧版字段 reasoning_content)。随后把同一模型接入 SK,调用 kernel.InvokePromptAsync 或 kernel.InvokeAsync,得到的 result.ToString() 为空串,或者 result.GetValue<string>() 返回 null。

这个现象容易被误判为两个极端:一是"SK 太封闭,不支持推理模型";二是"我配置写错了,改改参数就好"。本文认为,两种归因都过于粗糙。要真正定位,必须把一次调用拆成三段契约:

契约 A(模型 → 运行时):模型 tokenizer 输出的特殊 token(如 <think>...</think>)如何被推理引擎切分到不同字段。

契约 B(运行时 → 协议):Ollama 原生 API 与 OpenAI 兼容端点如何命名、序列化这些字段,流式与非流式是否一致。

契约 C(协议 → SDK 抽象):SK 的连接器反序列化时,把哪些 JSON 字段映射进 ChatMessageContent,未映射字段是否被静默丢弃。

"空结果"往往发生在契约 C:连接器把 choices[0].message.content 映射为正文,而推理内容位于另一个字段,未进入 SK 的内容模型;当模型在特定参数下把正文全部放入推理段、正文为空时,最终就表现为"空"。本文评述:把这类问题称为"框架不支持"并不准确,更精确的说法是"抽象层未建模该语义,且默认采用丢弃式反序列化"。这一区分直接决定了修复策略是"换框架"还是"补映射"。

二、背景与概念界定:reasoning content 到底是什么

2.1 从思维链到"可分离的推理段"

思维链(Chain-of-Thought, CoT)由 Wei 等人在 2022 年系统提出,核心是让模型在给出答案前生成中间推理步骤,从而提升多步推理任务的表现(Wei et al., 2022, NeurIPS)。但早期 CoT 的推理文本与最终答案混在同一段输出里,工程上难以分离。2024 年后,以 DeepSeek-R1 为代表的工作把"推理过程"显式建模为独立通道,通过强化学习训练模型在 <think> 标签内展开长推理,再输出正式答案(DeepSeek-AI, 2025, arXiv:2501.12948)。这一范式使"推理内容"成为可被 API 单独暴露的一等字段。

需要严格区分三个概念,否则后续讨论会失焦:

概念 定义 是否进入最终答案
思维链(CoT) 提示工程层面的"让模型分步说" 通常进入
推理内容(reasoning content) 模型运行时单独输出的思考段,协议层独立字段 通常不进入
最终答案(final answer) 面向用户的正式回复 是

本文评述:很多"空结果"报告其实混淆了这三者。若模型把全部输出放进推理段、最终答案为空,那么即便协议层完整暴露了推理字段,SK 侧取正文仍会为空——这不是 bug,而是模型行为与调用参数共同决定的边界条件。因此诊断第一步永远是:确认"空"发生在哪一段。

2.2 为什么 C# 开发者会踩这个坑

SK 的设计哲学是"多模型统一抽象":同一份 KernelFunction 可以在 OpenAI、Azure OpenAI、Ollama、HuggingFace 等连接器间切换。统一抽象的代价是取交集——只建模所有提供方都有的字段。reasoning_content 恰恰是 2024 年后才出现、且各家命名不一的新字段(OpenAI 用 reasoning_content 或 reasoning,Anthropic 用 thinking 块,Ollama 早期用 thinking,部分兼容层用 reasoning_content),天然落在交集之外。这是"抽象不支持"说法的合理内核,但它只解释了"为什么默认拿不到",没有解释"为什么是空而不是报错"。

答案在于反序列化策略:多数连接器使用 System.Text.Json 的默认行为,未知字段被忽略而非报错。于是推理内容被静默丢弃,正文若为空,最终结果就是空。这一"静默失败"特性是工程上最危险的部分——它不产生异常,只产生错误的数据。

三、协议层解剖:Ollama 原生 API 与 OpenAI 兼容端点

3.1 Ollama 原生 /api/chat 的字段形态

Ollama 的原生对话接口返回结构大致如下(字段随版本演进,以下为示意,实际以所装版本为准):

{
  "model": "qwen3:8b",
  "message": {
    "role": "assistant",
    "content": "最终答案文本",
    "thinking": "这里是推理过程……"
  },
  "done": true,
  "done_reason": "stop"
}

关键点有三:其一,推理内容与正文是同级字段,不是嵌套在 content 里的标签;其二,流式响应(stream: true)会逐块推送,thinking 与 content 可能分属不同 chunk,拼接逻辑必须分别累积;其三,字段名在不同版本间发生过变化(thinking 与 reasoning_content 都曾出现),这是"同一段代码在不同机器上表现不同"的常见根因。

提示:不要凭记忆写字段名。用 curl -s http://localhost:11434/api/chat -d '{...}' | jq 把真实响应打出来,字段名以本机输出为准。这是五步诊断法的第一步。

3.2 OpenAI 兼容端点的"翻译损耗"

Ollama 提供 /v1/chat/completions 兼容端点,SK 的 Ollama 连接器与 OpenAI 连接器都可能走这里。兼容层要做一次"Ollama 结构 → OpenAI 结构"的映射。问题在于:OpenAI 官方 Chat Completions 规范中,message 对象的字段集合是固定的(role、content、tool_calls 等),推理内容并非其标准字段。兼容层若严格按规范序列化,推理内容就会被丢弃;若选择扩展字段透传,则字段名由实现方自定。

这就解释了为什么"原生 API 有、兼容端点没有"是高频现象。本文评述:兼容层本质是一次有损编码(lossy encoding)。把它当作"等价通道"是危险的假设;工程上应把"原生端点"与"兼容端点"视为两条独立链路,分别验证。

3.3 流式与非流式的差异

非流式响应是一次性 JSON,字段完整;流式响应是 SSE 分块,每块可能只含 thinking 或只含 content。SK 在流式路径下返回 IAsyncEnumerable<StreamingChatMessageContent>,其内容类型枚举同样只覆盖标准语义。若开发者在流式回调里只取 content,推理块会被跳过;若只取最终聚合结果,则取决于聚合逻辑是否保留非正文块。这两条路径的失败模式不同,必须分开排查。

四、抽象层解剖:SK 的 ChatMessageContent 与内容类型

4.1 内容模型的设计取舍

SK 用 ChatMessageContent 表示一条消息,其 Items 集合可容纳文本、图像、函数调用等 KernelContent 子类。设计意图是"内容即对象",便于多模态扩展。但截至本文写作时,公开的内容类型中并没有"推理内容"这一子类。也就是说,即使连接器拿到了推理字段,也没有一个语义正确的容器去承载它——它要么被塞进普通文本(污染正文),要么被丢弃。

这带来一个微妙后果:FunctionResult.ToString() 的默认实现会拼接消息的文本内容。若推理内容未被建模,拼接结果自然不含它;若正文为空,拼接结果就是空串。这与观察到的现象完全吻合。

4.2 连接器的反序列化路径

以 Ollama 连接器为例,其响应 DTO 通常只声明 Message.Content 等标准字段。反序列化时,JSON 中多出的 thinking 字段因 DTO 未声明而被忽略。这是 .NET 序列化器的默认行为,不是连接器作者刻意"屏蔽"。本文评述:把责任归于"框架抽象"只对了一半;另一半是"DTO 未跟进协议演进"。前者是架构问题,后者是维护节奏问题,二者的修复成本差一个数量级。

重要提示:判断"是抽象问题还是配置问题"有一个快速判据——若原生 curl 有推理字段、SK 无,且换用不同版本 SK 后行为改变,则主要是 DTO/映射问题(可绕过);若所有版本、所有端点都拿不到,才更可能是抽象模型缺位(需扩展)。

五、复现与定位:五步诊断法

5.1 第一步:锁定真实响应字段

用原生端点发一次非流式请求,保存完整 JSON。重点确认:推理字段名、正文是否为空、done_reason 取值。若正文本身为空,问题在模型行为或参数,不在 SK。

5.2 第二步:验证兼容端点

对 /v1/chat/completions 发同样请求,对比字段差异。若兼容端点缺字段,则 SK 走该端点时必然拿不到,属协议层损耗。

5.3 第三步:抓 SK 的出站请求

在 SK 侧挂 HttpClient 的 DelegatingHandler,打印请求 URL 与响应原文。这一步能确认 SK 实际走的是哪个端点、请求体是否包含影响推理的开关(如 think、reasoning_effort 等)。

public sealed class RawLogHandler : DelegatingHandler
{
    protected override async Task<HttpResponseMessage> SendAsync(
        HttpRequestMessage request, CancellationToken ct)
    {
        var body = request.Content is null
            ? "(no body)"
            : await request.Content.ReadAsStringAsync(ct);
        Console.WriteLine($"[REQ] {request.Method} {request.RequestUri}");
        Console.WriteLine($"[REQ-BODY] {body}");

        var resp = await base.SendAsync(request, ct);
        var text = await resp.Content.ReadAsStringAsync(ct);
        Console.WriteLine($"[RESP] {(int)resp.StatusCode} {text}");
        return resp;
    }
}

5.4 第四步:最小 SK 复现

剥离业务逻辑,用最小代码复现,并同时打印 FunctionResult 的原始对象与字符串化结果,避免 ToString() 掩盖信息。

var builder = Kernel.CreateBuilder();
builder.AddOllamaChatCompletion(
    modelId: "qwen3:8b",
    endpoint: new Uri("http://localhost:11434"));

var kernel = builder.Build();
var result = await kernel.InvokePromptAsync("用三句话解释快速排序");

Console.WriteLine($"ValueType = {result.ValueType}");
Console.WriteLine($"ToString  = '{result}'");

if (result.Value is ChatMessageContent msg)
{
    Console.WriteLine($"Items.Count = {msg.Items.Count}");
    foreach (var item in msg.Items)
        Console.WriteLine($"  [{item.GetType().Name}] {item}");
}

5.5 第五步:版本对照

在隔离环境里切换 SK 与 Ollama 版本,记录行为变化。若某版本组合下推理字段能透出,说明是映射实现差异,可通过升级或自定义连接器解决;若始终不能,则需扩展内容模型。第五步的产出应是一张"版本 × 行为"矩阵,而非单点结论。

六、版本矩阵与关键数据:哪些组合会丢、哪些不会

下表为按诊断法整理的模拟矩阵(标注为模拟数据,用于说明判断逻辑,不代表任何具体版本的官方结论;实际请以本机抓包为准)。

组合(模拟) 端点 推理字段可见 SK 结果
Ollama 原生 + curl /api/chat 是 不涉及
Ollama 兼容 + curl /v1/chat/completions 视实现 不涉及
SK Ollama 连接器(旧) /api/chat DTO 未声明即丢 空/仅正文
SK OpenAI 连接器指向 Ollama /v1/chat/completions 取决于兼容层 空/仅正文
自定义连接器 + 扩展 DTO /api/chat 是 可取得

从矩阵可读出一个反直觉结论:"用 OpenAI 连接器指向 Ollama"并不比"用 Ollama 连接器"更可靠,因为前者多经一层兼容映射,损耗点更多。很多教程推荐这种"统一走 OpenAI 协议"的做法,在推理内容场景下反而更容易踩坑。本文评述:协议统一的价值在于可移植性,而非字段完整性;当新语义出现时,最短链路往往最先支持。

七、工程化修复路径:从绕行到根治

7.1 路径一:参数层绕行(成本最低)

若目标只是"拿到可用答案"而非"拿到思考过程",可通过提示词约束模型把答案写进正文,例如在系统消息中要求"最终答案必须放在正文,不要只输出思考"。同时检查是否误开了"仅思考"类参数。这条路径不改代码,适合快速止血,但不解决根因。

7.2 路径二:自定义 HttpClient 旁路(中等成本)

对需要推理内容的场景,绕开 SK 连接器,直接用 HttpClient 调原生端点,自行反序列化到包含推理字段的 DTO,再把结果封装回 FunctionResult 或自定义返回类型。优点是可控、可测;缺点是与 SK 的插件/函数编排体系脱节,需要自己做适配层。

public sealed record OllamaMessage(
    [property: JsonPropertyName("role")] string Role,
    [property: JsonPropertyName("content")] string? Content,
    [property: JsonPropertyName("thinking")] string? Thinking);

public sealed record OllamaChatResponse(
    [property: JsonPropertyName("message")] OllamaMessage Message);

// 注意:字段名以本机 /api/chat 实际输出为准,
// 不同版本可能是 thinking 或 reasoning_content。

7.3 路径三:扩展内容模型(根治)

在 SK 体系内新增一个 ReasoningContent : KernelContent 子类,并在自定义连接器中把推理字段实例化为该类型加入 Items。这样既保留 SK 的编排能力,又让推理内容有语义正确的容器。代价是需要维护自定义连接器,并关注上游 SK 后续是否原生支持——若原生支持,应及时迁移以免分叉。

工程建议:无论走哪条路径,都应在应用层定义统一的 ModelResponse { Answer, Reasoning, RawJson } 契约,把"协议差异"收敛在适配层内部。这样未来换模型、换框架时,业务代码零改动。

7.4 路径四:升级与跟踪上游

SK 迭代较快,连接器对推理字段的支持可能随版本补齐。建议在 CI 中加入"推理字段回归测试":固定模型与提示词,断言返回对象中存在非空推理内容。这样上游一旦支持或回退,测试会第一时间报警,而不是等用户反馈"又空了"。

八、对比与局限:四条技术路线的边界条件

路线 适用条件 主要局限
参数绕行 只需答案、不需思考过程 不解决根因,模型行为仍可能漂移
HttpClient 旁路 需要完整推理内容、可接受自维护 脱离 SK 编排,适配成本上升
扩展内容模型 长期项目、需与 SK 深度集成 需跟踪上游,存在分叉维护风险
升级跟踪 上游已支持或即将支持 受发布节奏约束,短期不可控

需要强调一个边界:本文讨论的是"推理内容不可见",而非"推理质量"。即便推理字段完整暴露,其内容也未必可靠——有研究指出模型生成的推理链可能包含事后合理化或错误步骤(Turpin et al., 2023, NeurIPS)。本文评述:因此把推理内容直接展示给终端用户需谨慎,更稳妥的用法是内部调试、审计与评测,而非当作"可信解释"。

九、推理内容治理与前沿预判

9.1 标准化仍在早期

截至本文写作,推理内容尚无跨厂商统一字段规范。OpenAI、Anthropic、Google 与开源推理引擎各有命名与结构。这种碎片化会持续一段时间。笔者认为:短期内更现实的路线不是等待标准,而是在应用层建立"语义归一化层",把各家字段映射为内部统一模型。这与数据库驱动层的历史演进类似——标准滞后,适配层先行。

9.2 安全与合规维度

推理内容可能泄露系统提示、内部策略或敏感中间结果。工程上应默认"推理内容不落库、不外传、仅在受控环境可见",并对日志做脱敏。这不是可选项:一旦推理段包含用户隐私或密钥片段,直接持久化会带来合规风险。

9.3 对 .NET 生态的预判

随着推理模型在私有化部署中普及,SK 这类统一抽象层大概率会引入"可选扩展字段"机制,而非把所有新字段塞进核心模型。开发者应关注两条线:连接器 DTO 的字段覆盖度,以及内容模型的可扩展性。前者决定"能不能拿到",后者决定"拿到后放哪"。

十、结论与可执行清单

回到最初的问题:kernel.InvokeAsync 返回空,既不能简单归为"框架抽象不支持",也不能简单归为"用户配置错误"。它是协议字段命名、兼容层有损映射、SK 内容模型缺位三者叠加的系统性语义丢失。定位的关键不是争论归因,而是按链路逐段验证。

可执行清单:

  1. 用原生 curl 确认推理字段名与正文是否为空;
  2. 对比兼容端点,判断是否存在协议损耗;
  3. 挂 DelegatingHandler 抓 SK 出站请求与响应原文;
  4. 最小代码复现,打印 Items 而非仅 ToString;
  5. 做版本矩阵,区分映射问题与模型缺位;
  6. 按需选择参数绕行、HttpClient 旁路或扩展内容模型;
  7. 在 CI 中加入推理字段回归断言;
  8. 应用层定义统一响应契约,收敛协议差异;
  9. 推理内容默认不落库、不外传,日志脱敏。

延伸阅读与实操参考(新窗口打开):Semantic Kernel 官方文档、Ollama API 文档、Semantic Kernel GitHub、DeepSeek-R1 论文。

主要参考文献

  1. Wei J., et al. Chain-of-Thought Prompting Elicits Reasoning in Large Language Models. NeurIPS 2022. arXiv:2201.11903.
  2. DeepSeek-AI. DeepSeek-R1: Incentivizing Reasoning Capability in LLMs via Reinforcement Learning. 2025. arXiv:2501.12948.
  3. Turpin M., et al. Language Models Don't Always Say What They Think. NeurIPS 2023. arXiv:2305.04388.
  4. Microsoft. Semantic Kernel Documentation. 2024–2025. learn.microsoft.com/semantic-kernel.
  5. Ollama. Ollama API Reference. 2024–2025. github.com/ollama/ollama/docs.
  6. OpenAI. Chat Completions API Reference. 2024–2025. platform.openai.com/docs.
  7. Anthropic. Extended Thinking Documentation. 2025. docs.anthropic.com.
  8. Qwen Team. Qwen3 Technical Report. 2025. arXiv:2505.09388.

文章声明:本文内容仅为作者学习、思考、经验、笔记的总结,仅供技术交流与参考。文中观点仅代表笔者个人思辨,不构成任何学术建议、商业建议或专业建议。所有数据来源已标注,引用时请以原始文献为准。

文中版本矩阵为模拟数据,仅用于说明诊断逻辑;实际字段名与行为请以本机抓包与官方文档为准。

内容仅供学习参考。如需引用,请以原始文献为准。

全文约 13600 字 | 参考文献 68 篇(主要 8 篇)。

🔒 复制本站文章内容需登录并达到 L3。当前:未登录

分享到

💬
微信
📷
朋友圈
🐧
QQ好友
🌐
QQ空间
👁
微博
📌
钉钉
🔗
复制链接
📑
复制图文

微信扫一扫分享

打开微信「扫一扫」,扫描二维码后在微信中分享给好友或朋友圈。

💬 评论 (0)

评论功能已关闭

⏸️ 本站暂未开放评论功能,不能进行评论,此为规划的后续开发预留
首页| 关于本网| 网站声明| 联系我们| 网站纠错| 服务| 网站地图
黔ICP备19010680号-1  |  邮箱:six528528@163.com
贵公网安备 52010302001819号
Copyright 2019-2026 http://www.databrush.com/ All rights reserved.
QQ
QQ扫一扫
Logo
DBN数据刷