有一种bug不会抛异常。日志里没有一行变红,测试也不会失败。推理模型第一轮答得很好,到了第二轮却像第一轮从未发生过。你先后怀疑提示词、温度参数、模型本身,真正的原因往往只是自己聊天循环里的一行代码。

机制是这样的:通过Microsoft.Extensions.AI调用 OpenAI 的 Responses API,使用推理模型,服务端不存储内容(store=false,即零数据保留),同时请求加密推理时,响应里会带一个加密推理项。在 .NET 中它表现为一个TextReasoningContent部分,含一个ProtectedData字符串,以及放在AdditionalProperties["reasoningItemId"]里的 item id。服务端不会替你记住它。下一轮如果不把它发回去,模型就无法延续自己的推理链。

有人基于 Microsoft.Extensions.AI 10.11 做了一个 .NET 10 小样例来定位这个问题。它跑在一个离线的假模型上,该假模型会输出真实的 TextReasoningContent 部分,并在回答里报告自己收到了多少个加密项(priorProtected=N),因此不用 API key 也能看到这个 bug。样例共38 个测试(29 个单元测试、9 个集成测试)。下面是被观察到推理内容消失的三个位置,每一处都配了测试。

要保住的是什么

推理调用返回的每条 assistant 消息,装的不只是文本。真正要紧的是这些部分。真实客户端来自 Responses 适配器,而不是 Chat Completions 那个:

  • OpenAIClient(apiKey).GetResponsesClient().AsIChatClient(modelId)拿到客户端(该 API 属实验性 OPENAI001,样例在 Directory.Build.props 里统一抑制了一次警告)。

记住这张表。下面三处泄漏归根到底是一件事:某个环节只留下了 TextContent,把其余部分扔了。

泄漏一:用 response.Text 重建 assistant 消息

几乎每篇聊天教程都教这一行:history.Add(new ChatMessage(ChatRole.Assistant, response.Text))。response.Text 只是文本部分的便捷视图,推理部分永远进不了历史。样例的 CLI 照此操作再追问时,打印结果是这样的:

  • Response had ProtectedData: True
  • History has ProtectedData after text-only append: False
  • Follow-up text: Offline (Medium, stateless, priorProtected=0): answered "Follow-up".

响应里有加密推理,历史里没有。追问发出去时,携带的先前推理项是零。修法是追加整个响应:history.AddMessages(response)。样例把它包在一个小助手ReasoningHistory.AppendResponse(history, response)后面,免得日后有人把它“简化”回去。测试故意展示失败:TextOnly_Append_Loses_ProtectedData断言历史里没有受保护的推理内容,而它的孪生测试AppendResponse_Preserves_ProtectedData用助手做同样的事,断言相反。样例 API 甚至有一个故意写错的端点POST /api/chat/turn/wrong,只追加文本,其响应报告 hasProtectedInResponse: true、hasProtectedInHistory: false,方便向团队演示这个 bug。

泄漏二:流式输出,加密块最后才到

流式让问题更糟,因为实时输出看起来是完整的。更新大致按这个顺序到达:先是推理摘要分块,然后是回答文本分块,最后是一个只带加密部分、没有任何可见文本的最终更新。样例的假模型复刻了这个顺序,并有单元测试盯着它,防止假模型跑偏。

常见的 UI 循环会把文本分块拼成一个字符串,再把这个字符串存成 assistant 消息。用户看到的内容都在,加密部分不在,因为它从来就没有文本。修法是保留每一次更新,把它们合并回一个响应再追加:遍历GetStreamingResponseAsync时把每个 update 收进列表,同时照常 yield 给 UI,最后调用updates.ToChatResponse()合并,再交给 AppendResponse。ToChatResponse() 会把分块合并成消息,于是摘要文本和最后一次更新里的 ProtectedData 都进了历史。演示在流结束后打印:After stream, history reasoning parts: 2, protected: True。测试StreamAsync_Coalesces_Into_History_With_ProtectedData排空流后断言 HasProtectedReasoning(history) 为真。

泄漏三:经过会话存储的那一趟

聊天历史迟早要离开内存,进缓存、进数据库、进 JSON 列,因为存在人工介入的暂停,或者有第二台服务器。这一趟有两种丢推理的方式:

  • DTO 只带 role 和 text。这就是泄漏一换了个地方,藏进了持久层。要存完整的内容列表,而不是拍平的字符串。
  • 回程时的类型变化。经过 System.Text.Json 往返后,AdditionalProperties 里的 reasoningItemId 值回来时是 JsonElement,不再是 string。写 value as string 的代码会悄悄拿到 null。

样例用防御式写法读这个 id:值若是 string 直接返回,若是 ValueKind 为 String 的 JsonElement 就取 GetString(),其余情况走 ToString()。测试GetReasoningItemId_Reads_JsonElement_After_Serialization把字典序列化、反序列化进一个新的 TextReasoningContent,再检查 id 仍能读回 "rsn_json"。

范围要说清楚:样例的 SessionStore 是内存实现(标注了“仅演示”),项目里没有数据库往返。它测的是最先咬人的那一环,也就是字典过一遍 JSON。

反向泄漏:别让它进日志

一旦把 ProtectedData 留在身边,下一个风险是它跑到不该去的地方,比如一个保留一年的日志索引。样例的ReasoningAuditChatClient是一个 DelegatingChatClient,只记录 effort、token 用量(含 ReasoningTokenCount)和截断的推理摘要,别的什么都不记。它还带了一个故意较真的绊线:AssertNoProtectedDataInLogPayload,即将写入日志的载荷里只要出现假模型的 enc_ 前缀就抛异常。这听起来偏执,直到有人在事故处理时加上一句“把整个响应都记下来”。

一个测试兜住三处

如果只加一个测试,就让它是对假模型跑的真实两轮对话,并让假模型报告自己收到了什么:MultiTurn_Preserves_Encrypted_Items先以低推理强度发第一轮,再以高推理强度发第二轮,断言第二轮文本里含 priorProtected=1,且历史里推理部分计数为 2。日后无论引入三处泄漏中的哪一处,第二轮都会以 priorProtected=0 回来,这个测试就会失败。

没有验证的部分

38 个测试全部跑在离线假模型上。它产出的内容类型与 OpenAI 适配器一致,但它不是 OpenAI,CI 从不发起真实调用。要在网络上做真正无状态的调用,还需要关掉存储输出并要求包含加密推理(项目 README 指向 ChatOptions.RawRepresentationFactory 和 CreateResponseOptions)。样例没有跑这条路径,依赖它之前请核对当前的 OpenAI .NET 文档。另外,如果使用带 ConversationId 的存储响应,服务端会保留先前的项,泄漏一的伤害会小一些;但为了回放和审计,你仍然希望自己的历史里存着完整消息。

完整可运行的版本包含 Core 库(历史助手、普通与流式轮次的聊天服务、推理审计中间件、离线推理客户端)、一个同时带正确与故意写错端点的最小 API、一个 CLI 演示和全部 38 个测试,外加一份分步 PDF 指南,无需 API key 即可运行。准备发起真实调用时,把 ReasoningChat:Provider 设为 openai,并通过 user-secrets 或环境变量提供密钥。

你的聊天历史在两轮之间存在哪里,推理部分有没有熬过这一趟,值得现在就查一遍。