diff --git a/AIChatPanel.xaml.cs b/AIChatPanel.xaml.cs
index 832f91b..e6ef214 100644
--- a/AIChatPanel.xaml.cs
+++ b/AIChatPanel.xaml.cs
@@ -364,6 +364,7 @@ namespace AnotherReplayReader
OnChunk,
_linkedCts.Token);
UpdateTokenDisplay(overviewResult);
+ ReportReasoningGuardResult(overviewResult);
_overview = OverviewParser.Parse(overviewResult.Response);
_overviewNarrative = _overview.Narrative;
AppendLog(
@@ -490,6 +491,7 @@ namespace AnotherReplayReader
OnChunk,
_linkedCts.Token);
UpdateTokenDisplay(segmentResult);
+ ReportReasoningGuardResult(segmentResult);
segmentResponse = segmentResult.Response;
if (backqueryCount >= maxBackqueriesPerSegment)
@@ -582,6 +584,7 @@ namespace AnotherReplayReader
_suppressDisplay = false;
}
UpdateTokenDisplay(revisionResult);
+ ReportReasoningGuardResult(revisionResult);
if (string.IsNullOrWhiteSpace(revisionResult.Response))
{
@@ -675,6 +678,7 @@ namespace AnotherReplayReader
_linkedCts.Token);
UpdateTokenDisplay(result);
+ ReportReasoningGuardResult(result);
// 总结轮回查:允许模型请求一次远处原始区间,作为同一会话的追加输入。
if (_replayData is null)
@@ -727,6 +731,7 @@ namespace AnotherReplayReader
OnChunk,
_linkedCts.Token);
UpdateTokenDisplay(finalSummary);
+ ReportReasoningGuardResult(finalSummary);
AppendLog("总结完成", "已根据回查区间补充最终总结。", false);
}
@@ -752,7 +757,9 @@ namespace AnotherReplayReader
var total = 0;
foreach (var message in messages)
{
- total += AiContextBudget.EstimateTokens(message.Content);
+ total += AiContextBudget.EstimateTokens(message.Content ?? string.Empty);
+ total += AiContextBudget.EstimateTokens(
+ message.ReasoningContent ?? string.Empty);
}
var check = AiContextBudget.CheckRequestUsage(
total, requestContext.Provider, requestContext.Model);
@@ -806,6 +813,44 @@ namespace AnotherReplayReader
_chunkQueue.Enqueue((chunk, DateTimeOffset.UtcNow));
}
+ private void ReportReasoningGuardResult(AIAnalyze.Result result)
+ {
+ if (!result.ReasoningInterrupted
+ && !result.ContinuationApplied
+ && string.IsNullOrWhiteSpace(result.ReasoningContinuationError))
+ {
+ return;
+ }
+
+ if (!string.IsNullOrWhiteSpace(result.ReasoningContinuationError))
+ {
+ AppendLog("推理保护警告", result.ReasoningContinuationError, false);
+ }
+ else if (result.ContinuationApplied)
+ {
+ AppendLog("推理保护", "已请求 AI 尽快收尾,并完成最终回答。", false);
+ }
+ else if (result.ReasoningInterrupted)
+ {
+ AppendLog("推理保护", "检测到推理内容超限,正在处理续写结果。", false);
+ }
+
+ if (!string.IsNullOrWhiteSpace(result.ReasoningOriginalRequestJson))
+ {
+ AppendLog(
+ "推理保护:首次请求消息",
+ result.ReasoningOriginalRequestJson,
+ true);
+ }
+ if (!string.IsNullOrWhiteSpace(result.ReasoningContinuationRequestJson))
+ {
+ AppendLog(
+ "推理保护:续写请求完整消息",
+ result.ReasoningContinuationRequestJson,
+ true);
+ }
+ }
+
private void StartThinkingBlock()
{
_thinkingStartTime = DateTime.Now;
@@ -1035,6 +1080,7 @@ namespace AnotherReplayReader
var thinkSb = new StringBuilder();
var contentSb = new StringBuilder();
var errorSb = new StringBuilder();
+ var guardTriggered = false;
while (_chunkQueue.TryDequeue(out var data))
{
var (chunk, time) = data;
@@ -1062,6 +1108,11 @@ namespace AnotherReplayReader
{
errorSb.AppendLine(chunk.Text);
}
+ else if (chunk.Type == AIAnalyze.AIChunkType.ReasoningGuard)
+ {
+ EndThinkingBlock();
+ guardTriggered = true;
+ }
}
if (thinkSb.Length > 0)
@@ -1080,6 +1131,10 @@ namespace AnotherReplayReader
{
AddErrorBlock(errorSb.ToString());
}
+ if (guardTriggered)
+ {
+ _currentContent = null;
+ }
}
private void AutoScroll()
diff --git a/AIProviderSettingsControl.xaml b/AIProviderSettingsControl.xaml
index 96be9b2..74bb221 100644
--- a/AIProviderSettingsControl.xaml
+++ b/AIProviderSettingsControl.xaml
@@ -143,6 +143,7 @@
+
@@ -164,20 +165,34 @@
Content="支持 SSE 流式输出"
Margin="0,5"/>
-
+
+
+
+
+
+
+
+
-
+
diff --git a/AIProviderSettingsControl.xaml.cs b/AIProviderSettingsControl.xaml.cs
index 7c5d90a..ac7b230 100644
--- a/AIProviderSettingsControl.xaml.cs
+++ b/AIProviderSettingsControl.xaml.cs
@@ -234,6 +234,11 @@ namespace AnotherReplayReader
_contextLengthBox.Text = _currentModel.ContextLength.ToString();
_contextBudgetBox.Text = _currentModel.ContextBudget is { } budget ? budget.ToString() : "0";
_supportsSseCheck.IsChecked = _currentModel.IsStream;
+ _reasoningGuardCheck.IsChecked = _currentModel.ReasoningGuardEnabled;
+ _reasoningTokenLimitBox.Text =
+ _currentModel.ReasoningGuardTokenLimit is { } guardLimit
+ ? guardLimit.ToString()
+ : "";
// 显示 ExtraParameters 为缩进 JSON
var json = JsonSerializer.Serialize(
@@ -288,6 +293,8 @@ namespace AnotherReplayReader
ContextLength = known.ContextLength,
ContextBudget = known.ContextBudget,
IsStream = known.IsStream,
+ ReasoningGuardEnabled = known.ReasoningGuardEnabled,
+ ReasoningGuardTokenLimit = known.ReasoningGuardTokenLimit,
ExtraParameters = new Dictionary(known.ExtraParameters)
});
}
@@ -376,6 +383,16 @@ namespace AnotherReplayReader
_currentModel.ContextBudget = null;
}
_currentModel.IsStream = _supportsSseCheck.IsChecked == true;
+ _currentModel.ReasoningGuardEnabled = _reasoningGuardCheck.IsChecked == true;
+ if (int.TryParse(_reasoningTokenLimitBox.Text, out int guardLimit)
+ && guardLimit > 0)
+ {
+ _currentModel.ReasoningGuardTokenLimit = guardLimit;
+ }
+ else
+ {
+ _currentModel.ReasoningGuardTokenLimit = null;
+ }
try
{
@@ -415,6 +432,8 @@ namespace AnotherReplayReader
_currentModel.IsStream = known.IsStream;
_currentModel.ContextLength = known.ContextLength;
_currentModel.ContextBudget = known.ContextBudget;
+ _currentModel.ReasoningGuardEnabled = known.ReasoningGuardEnabled;
+ _currentModel.ReasoningGuardTokenLimit = known.ReasoningGuardTokenLimit;
RefreshModelList();
_modelComboBox.SelectedItem = _modelComboBox.Items
.OfType()
@@ -433,6 +452,8 @@ namespace AnotherReplayReader
_contextLengthBox.Text = "";
_contextBudgetBox.Text = "";
_supportsSseCheck.IsChecked = false;
+ _reasoningGuardCheck.IsChecked = false;
+ _reasoningTokenLimitBox.Text = "";
_extraParamsBox.Text = "";
}
}
diff --git a/AI_REASONING_CONTINUATION_RESEARCH.md b/AI_REASONING_CONTINUATION_RESEARCH.md
index a180dd5..bfbd6a1 100644
--- a/AI_REASONING_CONTINUATION_RESEARCH.md
+++ b/AI_REASONING_CONTINUATION_RESEARCH.md
@@ -232,6 +232,18 @@ system
- 不要假设 `reasoning_content` 的末尾内容一定能被模型召回。
- 增加原始响应/请求日志,以便区分“模型未看到”和“模型看到了但没引用”。
+## 主项目集成状态(2026-08-23)
+
+- 主项目已落地模型级“推理保护”实验开关,默认关闭,仅在 `IsStream=true` 的模型上生效。
+- 累计推理 token 达到阈值后停止读取当前 SSE 响应,保留部分推理和正文;最多发起一次续写。
+- 续写采用本报告推荐的伪造 tool call 历史:`assistant(reasoning_content + tool_calls)` → `tool(tool_call_id + 收尾指令)`。
+- 首请求不携带 `tools`,只有续写请求注入 `tools` 与 `tool_choice=none`;缓存命中为 best-effort。
+- 保留推理在其末尾依次追加 `[INTERNAL_REASONING_TRUNCATED]` 与中文收尾句;收尾句不插入中间 checkpoint,也不提及“token limit/被截断”等技术细节。
+- 续写工具结果包含“进入收尾阶段、立即停止展开、直接输出最终结果”和“最后一句已经宣告收尾,请立即执行”等强化措辞。
+- UI 在触发推理保护后提供两个可折叠日志:首次请求消息与续写请求完整消息;用户可展开查看完整 `messages`、`tools`、`tool_choice` 和收尾指令。
+- 默认测试新增 `ReasoningGuard` 套件后总计 173 项通过;真实 API A/B 仍需要用户手工运行 `ARR_AI_E2E=1` 验证。
+- 可选真实环境测试为 `OpenCodeGoGuardE2e`:设置 `ARR_AI_GUARD_E2E=1` 启用,报告路径可用 `ARR_AI_GUARD_REPORT_PATH` 指定。
+
## 专用测试状态
- 测试代码:`AiV2.Tests/Program.cs` 中的 `OpenCodeGoFakeToolCallTests`。
diff --git a/AiV2.Tests/Program.cs b/AiV2.Tests/Program.cs
index 27a3081..2464aeb 100644
--- a/AiV2.Tests/Program.cs
+++ b/AiV2.Tests/Program.cs
@@ -15,6 +15,7 @@ using AnotherReplayReader;
using AnotherReplayReader.ReplayFile;
using AnotherReplayReader.Utils;
using AIAnalyze = AiV2.Tests.TestAIAnalyze;
+using MainAIAnalyze = AnotherReplayReader.Utils.AIAnalyze;
namespace AiV2.Tests
{
@@ -43,7 +44,9 @@ namespace AiV2.Tests
Run("UserKnowledgeOverlay", UserKnowledgeOverlayTests.Run);
Run("PromptBuilders", PromptBuildersTests.Run);
Run("RevisionFactsAndSerialization", RevisionFactsAndSerializationTests.Run);
+ Run("ReasoningGuard", ReasoningGuardTests.Run);
Run("OpenCodeGoFakeToolCallE2e", OpenCodeGoFakeToolCallTests.Run);
+ Run("OpenCodeGoGuardE2e", OpenCodeGoGuardE2e.Run);
Run("UserReplayFactIndexRepro", UserReplayFactIndexReproTests.Run);
Console.WriteLine();
@@ -966,6 +969,234 @@ namespace AiV2.Tests
}
}
+ internal sealed class GuardHttpHandler : HttpMessageHandler
+ {
+ public int RequestCount;
+ public string? FirstRequestBody;
+ public string? SecondRequestBody;
+ public bool FirstResponseContentOnly;
+ public bool SecondResponseInterrupted;
+
+ protected override async Task SendAsync(
+ HttpRequestMessage request,
+ CancellationToken cancellationToken)
+ {
+ var body = await request.Content!.ReadAsStringAsync();
+ RequestCount++;
+ if (RequestCount == 1)
+ {
+ FirstRequestBody = body;
+ if (FirstResponseContentOnly)
+ {
+ return CreateSse(
+ "data: {\"choices\":[{\"delta\":{\"content\":\"final\"}}]}\n",
+ "data: [DONE]\n");
+ }
+ return CreateSse(
+ "data: {\"choices\":[{\"delta\":{\"content\":\"partial first\"}}]}\n",
+ "data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"reasoning\"}}]}\n");
+ }
+
+ SecondRequestBody = body;
+ if (SecondResponseInterrupted)
+ {
+ return CreateSse(
+ "data: {\"choices\":[{\"delta\":{\"reasoning_content\":\"second reasoning\"}}]}\n");
+ }
+ return CreateSse(
+ "data: {\"choices\":[{\"delta\":{\"content\":\"final\"}}]}\n",
+ "data: [DONE]\n");
+ }
+
+ private static HttpResponseMessage CreateSse(params string[] lines)
+ {
+ return new HttpResponseMessage(System.Net.HttpStatusCode.OK)
+ {
+ Content = new StringContent(
+ string.Concat(lines),
+ Encoding.UTF8,
+ "text/event-stream")
+ };
+ }
+ }
+
+ internal static class ReasoningGuardTests
+ {
+ public static void Run()
+ {
+ TestPrepareReasoning();
+ TestToolPrompt();
+ TestMessageSerialization();
+ TestDisabledAsync().GetAwaiter().GetResult();
+ TestGuardedContinuationAsync().GetAwaiter().GetResult();
+ TestFallbackAsync().GetAwaiter().GetResult();
+ }
+
+ private static AiProvider CreateProvider() =>
+ new()
+ {
+ Name = "测试",
+ BaseUrl = "http://localhost/v1",
+ ApiKey = "test-key",
+ DefaultMaxTokens = 16384
+ };
+
+ private static AiModel CreateModel(bool enabled = true) =>
+ new()
+ {
+ ModelId = "deepseek-v4-flash",
+ IsStream = true,
+ ContextLength = 1_000_000,
+ ContextBudget = 160_000,
+ ReasoningGuardEnabled = enabled,
+ ReasoningGuardTokenLimit = enabled ? 1 : null,
+ ExtraParameters = new Dictionary()
+ };
+
+ private static ImmutableList CreateMessages() =>
+ ImmutableList.Empty
+ .Add(new MainAIAnalyze.ChatMessage("system", "测试系统"))
+ .Add(new MainAIAnalyze.ChatMessage("user", "请分析。"));
+
+ private static async Task CompleteAsync(
+ GuardHttpHandler handler,
+ AiModel model)
+ {
+ using var http = new HttpClient(handler);
+ var analyzer = new MainAIAnalyze(http);
+ return await analyzer.CompleteAsync(
+ CreateMessages(),
+ new AiRequestContext(CreateProvider(), model),
+ _ => { },
+ CancellationToken.None);
+ }
+
+ private static void TestPrepareReasoning()
+ {
+ var shortReasoning = "第一行。\n第二行。\n第三行。";
+ var prepared = AiReasoningGuard.PrepareReasoning(shortReasoning, 1000);
+ Program.Assert(
+ prepared.Text.TrimEnd().EndsWith(AiReasoningGuard.WrapUpStatement),
+ "末尾收尾语句");
+ Program.Assert(
+ prepared.Text.IndexOf(AiReasoningGuard.TruncationMarker) >= 0
+ && prepared.Text.IndexOf(AiReasoningGuard.TruncationMarker)
+ == prepared.Text.LastIndexOf(AiReasoningGuard.TruncationMarker),
+ "只追加一次截断标记");
+ Program.Assert(
+ prepared.Text.IndexOf(AiReasoningGuard.TruncationMarker)
+ < prepared.Text.IndexOf(AiReasoningGuard.WrapUpStatement),
+ "截断标记在收尾语句之前");
+
+ var longReasoning = "前文。\n" + new string('x', 500) + "后文。";
+ var truncated = AiReasoningGuard.PrepareReasoning(longReasoning, 10);
+ Program.Assert(!truncated.Text.Contains("后文"), "超限部分被截掉");
+
+ var formatState = AiReasoningGuard.DescribeFormatState(
+ "```json\n{\"unitClaims\":[");
+ Program.Assert(
+ formatState.Contains("代码围栏未闭合")
+ && formatState.Contains("JSON"),
+ "格式状态检测");
+ }
+
+ private static void TestToolPrompt()
+ {
+ var result = AiReasoningGuard.BuildToolResult();
+ Program.Assert(
+ result.Contains("立即停止继续展开推理")
+ && result.Contains("直接输出最终结果")
+ && result.Contains("最后一句已经宣告收尾"),
+ "强收尾措辞");
+ Program.Assert(
+ !result.Contains("上一轮思维链")
+ && !result.Contains("截断点")
+ && !result.Contains("My reasoning has been truncated")
+ && !result.Contains("token limit"),
+ "避免歧义措辞");
+ }
+
+ private static void TestMessageSerialization()
+ {
+ var assistant = MainAIAnalyze.ChatMessage.AssistantToolCall(
+ "thinking",
+ "call_1",
+ "analysis_hint",
+ "{\"instruction\":\"wrap up\"}");
+ var assistantJson = JsonSerializer.Serialize(assistant);
+ Program.Assert(
+ assistantJson.Contains("\"reasoning_content\":\"thinking\"")
+ && assistantJson.Contains("\"tool_calls\"")
+ && assistantJson.Contains("\"analysis_hint\""),
+ "assistant 工具历史序列化");
+
+ var tool = MainAIAnalyze.ChatMessage.ToolResult(
+ "call_1",
+ AiReasoningGuard.BuildToolResult());
+ var toolJson = JsonSerializer.Serialize(tool);
+ Program.Assert(
+ toolJson.Contains("\"tool_call_id\":\"call_1\""),
+ "tool 结果序列化");
+ }
+
+ private static async Task TestDisabledAsync()
+ {
+ var handler = new GuardHttpHandler { FirstResponseContentOnly = true };
+ var result = await CompleteAsync(handler, CreateModel(enabled: false));
+ Program.AssertEqual(1, handler.RequestCount, "未启用时只发一次请求");
+ Program.Assert(!result.ContinuationApplied, "未启用不续写");
+ Program.AssertEqual("final", result.Response, "未启用保持普通结果");
+ }
+
+ private static async Task TestGuardedContinuationAsync()
+ {
+ var handler = new GuardHttpHandler();
+ var result = await CompleteAsync(handler, CreateModel());
+ Program.AssertEqual(2, handler.RequestCount, "触发后发起一次续写");
+ Program.Assert(result.ContinuationApplied, "续写成功");
+ Program.AssertEqual("final", result.Response, "续写返回最终正文");
+ Program.Assert(
+ handler.FirstRequestBody is { } firstBody
+ && !firstBody.Contains("\"tools\""),
+ "首次请求不携带 tool 定义");
+ Program.Assert(
+ handler.SecondRequestBody is { } secondBody
+ && secondBody.Contains("\"tools\"")
+ && secondBody.Contains("\"tool_choice\":\"none\"")
+ && secondBody.Contains("\"reasoning_content\"")
+ && secondBody.Contains("\"tool_calls\"")
+ && secondBody.Contains("\"tool_call_id\""),
+ "续写请求携带工具历史");
+ Program.Assert(
+ result.ReasoningOriginalRequestJson is { } originalJson
+ && originalJson.Contains("\"messages\""),
+ "首次请求 JSON 回传 UI");
+ Program.Assert(
+ result.ReasoningContinuationRequestJson is { } continuationJson
+ && continuationJson.Contains("\"tools\"")
+ && continuationJson.Contains("\"reasoning_content\"")
+ && continuationJson.Contains("我已经整理出足够的信息"),
+ "修改后请求 JSON 回传 UI");
+ }
+
+ private static async Task TestFallbackAsync()
+ {
+ var handler = new GuardHttpHandler { SecondResponseInterrupted = true };
+ var result = await CompleteAsync(handler, CreateModel());
+ Program.Assert(!result.ContinuationApplied, "二次超限不再续写");
+ Program.Assert(result.ReasoningInterrupted, "保留中断状态");
+ Program.Assert(
+ result.ReasoningContinuationError is { } error
+ && error.Contains("回退"),
+ "回退提示");
+ Program.Assert(
+ result.ReasoningContinuationRequestJson is { } continuationJson
+ && continuationJson.Contains("\"tools\""),
+ "回退时仍提供修改后请求 JSON");
+ Program.AssertEqual("partial first", result.Response, "回退首次部分正文");
+ }
+ }
+
internal static class OpenCodeGoFakeToolCallTests
{
private const string SystemPrompt =
@@ -1382,7 +1613,7 @@ namespace AiV2.Tests
report.AppendLine("```");
}
- private static string FindSettingsPath()
+ internal static string FindSettingsPath()
{
var explicitPath = Environment.GetEnvironmentVariable("ARR_AI_SETTINGS_PATH");
if (!string.IsNullOrWhiteSpace(explicitPath) && File.Exists(explicitPath))
@@ -1412,6 +1643,97 @@ namespace AiV2.Tests
}
}
+ internal static class OpenCodeGoGuardE2e
+ {
+ public static void Run()
+ {
+ if (Environment.GetEnvironmentVariable("ARR_AI_GUARD_E2E") != "1")
+ {
+ Console.WriteLine(
+ " [跳过] 未设置 ARR_AI_GUARD_E2E=1,跳过真实推理保护实验");
+ return;
+ }
+ RunAsync().GetAwaiter().GetResult();
+ }
+
+ private static async Task RunAsync()
+ {
+ var settingsPath = OpenCodeGoFakeToolCallTests.FindSettingsPath();
+ var settings = JsonSerializer.Deserialize(
+ File.ReadAllText(settingsPath, Encoding.UTF8))
+ ?? throw new InvalidOperationException("AI 设置文件无法解析");
+ var provider = settings.Providers.FirstOrDefault(p =>
+ p.Name.Equals("OpenCodeGo", StringComparison.OrdinalIgnoreCase)
+ && p.Models.Any(m =>
+ m.ModelId.Equals("deepseek-v4-flash", StringComparison.OrdinalIgnoreCase)))
+ ?? throw new InvalidOperationException(
+ "当前配置中未找到 OpenCodeGo / deepseek-v4-flash 服务与模型");
+ var model = provider.Models.First(m =>
+ m.ModelId.Equals("deepseek-v4-flash", StringComparison.OrdinalIgnoreCase));
+ if (!model.IsStream)
+ {
+ Console.WriteLine(
+ " [跳过] OpenCodeGo/deepseek-v4-flash 当前配置为非流式模型,无法验证中断逻辑");
+ return;
+ }
+ if (string.IsNullOrWhiteSpace(provider.ApiKey))
+ {
+ throw new InvalidOperationException("OpenCodeGo 的 API Key 为空");
+ }
+
+ var guardModel = new AiModel
+ {
+ ModelId = model.ModelId,
+ DisplayName = model.DisplayName,
+ IsStream = model.IsStream,
+ ContextLength = model.ContextLength,
+ ContextBudget = model.ContextBudget,
+ ReasoningGuardEnabled = true,
+ ReasoningGuardTokenLimit = 1,
+ ExtraParameters = new Dictionary(model.ExtraParameters)
+ };
+ var messages = ImmutableList.Empty
+ .Add(new MainAIAnalyze.ChatMessage(
+ "system",
+ "请先进行详细推理,再给出简短结论。"))
+ .Add(new MainAIAnalyze.ChatMessage(
+ "user",
+ "请逐条说明分析步骤,最后用一到两句话给出结论。"));
+
+ var analyzer = new MainAIAnalyze();
+ var result = await analyzer.CompleteAsync(
+ messages,
+ new AiRequestContext(provider, guardModel),
+ _ => { },
+ CancellationToken.None);
+ var report = new StringBuilder();
+ report.AppendLine("# AI 推理保护 E2E 报告");
+ report.AppendLine();
+ report.AppendLine($"- Provider:{provider.Name}");
+ report.AppendLine($"- Model:{guardModel.ModelId}");
+ report.AppendLine($"- ReasoningInterrupted:{result.ReasoningInterrupted}");
+ report.AppendLine($"- ContinuationApplied:{result.ContinuationApplied}");
+ report.AppendLine($"- Reasoning 长度:{result.Reasoning?.Length ?? 0}");
+ report.AppendLine($"- Response 长度:{result.Response.Length}");
+ report.AppendLine($"- FormatState:{result.ReasoningFormatState ?? "(无)"}");
+ if (!string.IsNullOrWhiteSpace(result.ReasoningContinuationError))
+ {
+ report.AppendLine($"- Error:{result.ReasoningContinuationError}");
+ }
+
+ var reportPath = Environment.GetEnvironmentVariable("ARR_AI_GUARD_REPORT_PATH");
+ if (!string.IsNullOrWhiteSpace(reportPath))
+ {
+ File.WriteAllText(reportPath, report.ToString(), new UTF8Encoding(false));
+ Console.WriteLine($" [E2E-GUARD] 报告已写入:{reportPath}");
+ }
+ Console.WriteLine(
+ $" [E2E-GUARD] interrupted={result.ReasoningInterrupted}; "
+ + $"continued={result.ContinuationApplied}; "
+ + $"reasoning={result.Reasoning?.Length ?? 0}; response={result.Response.Length}");
+ }
+ }
+
internal static class UserReplayFactIndexReproTests
{
public static void Run()
diff --git a/PLAN_ai_analysis_v2.md b/PLAN_ai_analysis_v2.md
index ba3197f..8a4182b 100644
--- a/PLAN_ai_analysis_v2.md
+++ b/PLAN_ai_analysis_v2.md
@@ -48,6 +48,14 @@
- `AiV2.Tests` 仅保留 `OpenCodeGoFakeToolCallE2e` 专用测试:默认跳过,需同时设置 `ARR_AI_E2E=1` 与 `ARR_AI_E2E_TOOL=1`。
- 实验结果、推荐方案与数据表格见 `AI_REASONING_CONTINUATION_RESEARCH.md`。
+### 2026-08-23 推理保护实现
+
+- 主项目已实现默认关闭、模型级配置的推理保护:累计 `reasoning_content` 达到阈值后中断流式响应,并通过研究验证的 tool call 载体请求一次续写。
+- 续写只在保留推理末尾依次追加 `[INTERNAL_REASONING_TRUNCATED]` 与中文收尾句,不插入中间 checkpoint;首请求不携带 `tools`,续写请求才注入工具定义和 `tool_choice=none`。
+- `ChatMessage` 支持 `reasoning_content`/`tool_calls`/`tool_call_id`,`Result` 保留完整推理、中断/续写状态与预算/错误信息。
+- UI 触发推理保护后会显示首次请求与续写请求的完整消息日志;日志默认折叠,用户可展开检查 `messages`/`tools`/`tool_choice`。
+- `AiV2.Tests` 新增 `ReasoningGuard` 测试套件;默认测试总计 173 项通过。
+
## 1. 背景与目标
应用现有 AI 分析流程为"全量日志 + LLM 分段建议 + 分段分析 + 总结",经旧文档与代码审视,存在四类问题:上下文膨胀(每轮重发全量日志)、验证层可信度(施法者/目标混淆、所有权证据缺失、解析脆弱)、知识层作用域(结构化数据无 mod 维度、提示词与验证知识漂移)、修订机制未落地。
diff --git a/Utils/AIAnalyze.cs b/Utils/AIAnalyze.cs
index 0e8c17b..3e181ed 100644
--- a/Utils/AIAnalyze.cs
+++ b/Utils/AIAnalyze.cs
@@ -7,6 +7,7 @@ using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
+using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
@@ -20,7 +21,54 @@ namespace AnotherReplayReader.Utils
/// 聊天消息。属性名保持小写以兼容 OpenAI 兼容端点。
public sealed record ChatMessage(
[property: System.Text.Json.Serialization.JsonPropertyName("role")] string Role,
- [property: System.Text.Json.Serialization.JsonPropertyName("content")] string Content);
+ [property: System.Text.Json.Serialization.JsonPropertyName("content"),
+ System.Text.Json.Serialization.JsonIgnore(
+ Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] string? Content = null,
+ [property: System.Text.Json.Serialization.JsonPropertyName("reasoning_content"),
+ System.Text.Json.Serialization.JsonIgnore(
+ Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] string? ReasoningContent = null,
+ [property: System.Text.Json.Serialization.JsonPropertyName("tool_calls"),
+ System.Text.Json.Serialization.JsonIgnore(
+ Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] IReadOnlyList? ToolCalls = null,
+ [property: System.Text.Json.Serialization.JsonPropertyName("tool_call_id"),
+ System.Text.Json.Serialization.JsonIgnore(
+ Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] string? ToolCallId = null)
+ {
+ public sealed record ToolCall(
+ [property: System.Text.Json.Serialization.JsonPropertyName("id")] string Id,
+ [property: System.Text.Json.Serialization.JsonPropertyName("type")] string Type,
+ [property: System.Text.Json.Serialization.JsonPropertyName("function")] ToolCallFunction Function);
+
+ public sealed record ToolCallFunction(
+ [property: System.Text.Json.Serialization.JsonPropertyName("name")] string Name,
+ [property: System.Text.Json.Serialization.JsonPropertyName("arguments")] string Arguments);
+
+ public static ChatMessage Assistant(string? content, string? reasoningContent = null) =>
+ new(
+ "assistant",
+ string.IsNullOrEmpty(content) ? null : content,
+ string.IsNullOrEmpty(reasoningContent) ? null : reasoningContent);
+
+ public static ChatMessage AssistantToolCall(
+ string? reasoningContent,
+ string toolCallId,
+ string toolName,
+ string arguments) =>
+ new(
+ "assistant",
+ null,
+ string.IsNullOrEmpty(reasoningContent) ? null : reasoningContent,
+ new[]
+ {
+ new ToolCall(
+ toolCallId,
+ "function",
+ new ToolCallFunction(toolName, arguments))
+ });
+
+ public static ChatMessage ToolResult(string toolCallId, string content) =>
+ new("tool", content, null, null, toolCallId);
+ }
public static string GetSystemPrompt(
Replay replay,
@@ -1162,6 +1210,7 @@ PlayerA: 开始出兵
public enum AIChunkType
{
Reasoning,
+ ReasoningGuard,
Content,
Error,
Json
@@ -1176,6 +1225,14 @@ PlayerA: 开始出兵
public struct Result
{
public string Response;
+ public string Reasoning;
+ public string? ReasoningFormatState;
+ public bool ReasoningInterrupted;
+ public bool ContinuationApplied;
+ public string? ReasoningContinuationError;
+ public string? RequestJson;
+ public string? ReasoningOriginalRequestJson;
+ public string? ReasoningContinuationRequestJson;
public int? PromptTokens;
public int? CompletionTokens;
public int? TotalTokens;
@@ -1185,36 +1242,243 @@ PlayerA: 开始出兵
private readonly HttpClient _http;
public AIAnalyze()
- {
- _http = new HttpClient
+ : this(new HttpClient
{
Timeout = TimeSpan.FromMinutes(5),
- };
+ })
+ {
+ }
+
+ internal AIAnalyze(HttpClient http)
+ {
+ _http = http ?? throw new ArgumentNullException(nameof(http));
}
/// 对给定的消息列表发起一次完整的 chat completion 请求(流式),不修改内部状态。
public async Task CompleteAsync(
ImmutableList messages,
AiRequestContext requestContext,
- Action onChunk,
+ Action? onChunk,
CancellationToken cancellationToken)
{
- return await Task.Run(
- () => DoRequest(_http, messages, requestContext, onChunk, cancellationToken),
+ var model = requestContext.Model;
+ var guardEnabled = model.ReasoningGuardEnabled && model.IsStream;
+ if (!guardEnabled)
+ {
+ return await Task.Run(
+ () => DoRequest(_http, messages, requestContext, onChunk, cancellationToken),
+ cancellationToken);
+ }
+
+ var tokenLimit = AiReasoningGuard.GetEffectiveTokenLimit(
+ requestContext.Provider, model);
+ var firstResult = await Task.Run(
+ () => DoRequest(
+ _http,
+ messages,
+ requestContext,
+ onChunk,
+ cancellationToken,
+ tokenLimit),
cancellationToken);
+ if (!firstResult.ReasoningInterrupted)
+ {
+ return firstResult;
+ }
+
+ var continuationMessages = BuildReasoningContinuationMessages(
+ messages,
+ firstResult.Reasoning,
+ tokenLimit,
+ out var formatState);
+ firstResult.ReasoningFormatState = formatState;
+ var continuationContext = BuildReasoningToolContext(requestContext);
+ var originalRequestJson = firstResult.RequestJson;
+ var continuationRequestJson = SerializeRequestParams(
+ continuationMessages,
+ continuationContext,
+ writeIndented: true);
+ var continuationPromptTokens = 0;
+ foreach (var message in continuationMessages)
+ {
+ continuationPromptTokens += AiContextBudget.EstimateTokens(
+ (message.Content ?? string.Empty)
+ + "\n"
+ + (message.ReasoningContent ?? string.Empty));
+ }
+ var continuationCheck = AiContextBudget.CheckRequestUsage(
+ continuationPromptTokens,
+ requestContext.Provider,
+ continuationContext.Model);
+ var continuationBudgetWarning = string.Empty;
+ if (continuationCheck.Block)
+ {
+ var message = "续写请求未通过预算检查:" + continuationCheck.Message;
+ if (!string.IsNullOrEmpty(firstResult.Response))
+ {
+ firstResult.ReasoningContinuationError = message;
+ firstResult.ContinuationApplied = false;
+ firstResult.ReasoningOriginalRequestJson = originalRequestJson;
+ firstResult.ReasoningContinuationRequestJson =
+ continuationRequestJson;
+ return firstResult;
+ }
+ throw new InvalidOperationException(message);
+ }
+ if (!continuationCheck.IsOk)
+ {
+ continuationBudgetWarning = continuationCheck.Message;
+ }
+
+ Result secondResult = default;
+ var continuationError = string.Empty;
+ try
+ {
+ secondResult = await Task.Run(
+ () => DoRequest(
+ _http,
+ continuationMessages,
+ continuationContext,
+ onChunk,
+ cancellationToken,
+ tokenLimit),
+ cancellationToken);
+ }
+ catch (Exception ex) when (
+ !cancellationToken.IsCancellationRequested
+ && ex is not OperationCanceledException)
+ {
+ continuationError = ex.Message;
+ }
+
+ if (!string.IsNullOrEmpty(continuationError))
+ {
+ if (!string.IsNullOrEmpty(firstResult.Response))
+ {
+ firstResult.ReasoningContinuationError =
+ "续写请求失败,已保留首次部分正文:" + continuationError;
+ firstResult.ContinuationApplied = false;
+ firstResult.ReasoningOriginalRequestJson = originalRequestJson;
+ firstResult.ReasoningContinuationRequestJson =
+ continuationRequestJson;
+ return firstResult;
+ }
+
+ throw new InvalidOperationException(
+ "推理续写失败,且没有可用的部分回答:" + continuationError);
+ }
+
+ if (secondResult.ReasoningInterrupted)
+ {
+ if (!string.IsNullOrEmpty(secondResult.Response))
+ {
+ secondResult.ReasoningContinuationError =
+ "续写仍超过推理上限,已使用续写部分正文。";
+ secondResult.ContinuationApplied = false;
+ secondResult.ReasoningOriginalRequestJson = originalRequestJson;
+ secondResult.ReasoningContinuationRequestJson =
+ continuationRequestJson;
+ return secondResult;
+ }
+
+ if (!string.IsNullOrEmpty(firstResult.Response))
+ {
+ firstResult.ReasoningContinuationError =
+ "续写仍超过推理上限,已回退到首次部分正文。";
+ firstResult.ContinuationApplied = false;
+ firstResult.ReasoningOriginalRequestJson = originalRequestJson;
+ firstResult.ReasoningContinuationRequestJson =
+ continuationRequestJson;
+ return firstResult;
+ }
+
+ throw new InvalidOperationException(
+ "推理续写仍超过上限,且没有可用的部分回答。");
+ }
+
+ secondResult.ContinuationApplied = true;
+ secondResult.ReasoningOriginalRequestJson = originalRequestJson;
+ secondResult.ReasoningContinuationRequestJson =
+ continuationRequestJson;
+ if (!string.IsNullOrEmpty(continuationBudgetWarning))
+ {
+ secondResult.ReasoningContinuationError = continuationBudgetWarning;
+ }
+ return secondResult;
+ }
+
+ private static ImmutableList BuildReasoningContinuationMessages(
+ ImmutableList messages,
+ string reasoning,
+ int tokenLimit,
+ out string formatState)
+ {
+ var prepared = AiReasoningGuard.PrepareReasoning(reasoning, tokenLimit);
+ formatState = prepared.FormatState;
+ var instruction = AiReasoningGuard.BuildToolInstruction();
+ var arguments = JsonSerializer.Serialize(new { instruction });
+ return messages
+ .Add(ChatMessage.AssistantToolCall(
+ prepared.Text,
+ AiReasoningGuard.ToolCallId,
+ AiReasoningGuard.ToolName,
+ arguments))
+ .Add(ChatMessage.ToolResult(
+ AiReasoningGuard.ToolCallId,
+ AiReasoningGuard.BuildToolResult()));
+ }
+
+ private static AiRequestContext BuildReasoningToolContext(
+ AiRequestContext context)
+ {
+ var model = new AiModel
+ {
+ ModelId = context.Model.ModelId,
+ DisplayName = context.Model.DisplayName,
+ IsStream = context.Model.IsStream,
+ ContextLength = context.Model.ContextLength,
+ ContextBudget = context.Model.ContextBudget,
+ ReasoningGuardEnabled = context.Model.ReasoningGuardEnabled,
+ ReasoningGuardTokenLimit = context.Model.ReasoningGuardTokenLimit,
+ ExtraParameters = new Dictionary(
+ context.Model.ExtraParameters)
+ };
+ model.ExtraParameters["tools"] =
+ new[] { AiReasoningGuard.BuildToolDefinition() };
+ model.ExtraParameters["tool_choice"] = "none";
+ return new AiRequestContext(context.Provider, model);
+ }
+
+ private static string SerializeRequestParams(
+ ImmutableList messages,
+ AiRequestContext context,
+ bool writeIndented = false)
+ {
+ var requestParams = ProcessRequestParams(
+ messages,
+ context.BuildRequestParams());
+ return JsonSerializer.Serialize(
+ requestParams,
+ writeIndented
+ ? new JsonSerializerOptions
+ {
+ WriteIndented = true,
+ Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
+ }
+ : null);
}
private static async Task DoRequest(
HttpClient http,
ImmutableList messages,
AiRequestContext requestContext,
- Action onChunk,
- CancellationToken cancellationToken)
+ Action? onChunk,
+ CancellationToken cancellationToken,
+ int? reasoningTokenLimit = null)
{
var provider = requestContext.Provider;
- var requestParams = ProcessRequestParams(messages, requestContext.BuildRequestParams());
var isStream = requestContext.Model.IsStream;
- var inputJson = JsonSerializer.Serialize(requestParams);
+ var inputJson = SerializeRequestParams(messages, requestContext);
var uri = new Uri(new(provider.BaseUrl.TrimEnd('/') + "/"), "chat/completions");
using var request = new HttpRequestMessage(HttpMethod.Post, uri);
@@ -1230,7 +1494,10 @@ PlayerA: 开始出兵
using var reader = new StreamReader(responseStream);
var fullBuilder = new StringBuilder();
+ var reasoningBuilder = new StringBuilder();
var result = new Result();
+ result.RequestJson = inputJson;
+ var interrupted = false;
// 根据模式分别读取响应
// if response.Content.Headers.ContentType is "text/event-stream", then it's stream mode, otherwise it's non-stream mode
@@ -1268,17 +1535,53 @@ PlayerA: 开始出兵
});
using var doc = JsonDocument.Parse(data);
- ProcessJsonDocument(doc, isStream: true, fullBuilder, result, onChunk);
+ ProcessJsonDocument(
+ doc,
+ isStream: true,
+ fullBuilder,
+ reasoningBuilder,
+ result,
+ onChunk);
+
+ if (reasoningTokenLimit is { } guardLimit
+ && guardLimit > 0
+ && AiContextBudget.EstimateTokens(reasoningBuilder.ToString())
+ >= guardLimit)
+ {
+ interrupted = true;
+ onChunk?.Invoke(new AIChunk
+ {
+ Type = AIChunkType.ReasoningGuard,
+ Text = "检测到推理内容超过保护阈值,正在请求 AI 尽快收尾..."
+ });
+ break;
+ }
}
}
else
{
var json = await reader.ReadToEndAsync();
using var doc = JsonDocument.Parse(json);
- ProcessJsonDocument(doc, isStream: false, fullBuilder, result, onChunk);
+ ProcessJsonDocument(
+ doc,
+ isStream: false,
+ fullBuilder,
+ reasoningBuilder,
+ result,
+ onChunk);
}
response.EnsureSuccessStatusCode();
+ result.Reasoning = reasoningBuilder.ToString();
+ result.ReasoningFormatState = AiReasoningGuard.DescribeFormatState(
+ result.Reasoning);
+ if (interrupted)
+ {
+ result.Response = fullBuilder.ToString();
+ result.ReasoningInterrupted = true;
+ return result;
+ }
+
if (fullBuilder.Length == 0)
{
throw new Exception("AI分析失败,返回内容为空");
@@ -1296,8 +1599,9 @@ PlayerA: 开始出兵
JsonDocument doc,
bool isStream,
StringBuilder fullBuilder,
+ StringBuilder reasoningBuilder,
Result result,
- Action onChunk)
+ Action? onChunk)
{
// 提取 usage(如果存在)
if (doc.RootElement.TryGetProperty("usage", out var usage) && usage.ValueKind == JsonValueKind.Object)
@@ -1344,7 +1648,11 @@ PlayerA: 开始出兵
? choice.GetProperty("delta")
: choice.GetProperty("message");
- ExtractContentFromObject(contentObj, fullBuilder, onChunk);
+ ExtractContentFromObject(
+ contentObj,
+ fullBuilder,
+ reasoningBuilder,
+ onChunk);
}
}
@@ -1354,7 +1662,8 @@ PlayerA: 开始出兵
private static void ExtractContentFromObject(
JsonElement contentObj,
StringBuilder fullBuilder,
- Action onChunk)
+ StringBuilder reasoningBuilder,
+ Action? onChunk)
{
// 普通内容
if (contentObj.TryGetProperty("content", out var content))
@@ -1362,11 +1671,11 @@ PlayerA: 开始出兵
var text = content.GetString();
if (!string.IsNullOrEmpty(text))
{
- fullBuilder.Append(text);
+ fullBuilder.Append(text!);
onChunk?.Invoke(new AIChunk
{
Type = AIChunkType.Content,
- Text = text
+ Text = text!
});
}
}
@@ -1377,10 +1686,11 @@ PlayerA: 开始出兵
var text = reasoning.GetString();
if (!string.IsNullOrEmpty(text))
{
+ reasoningBuilder.Append(text!);
onChunk?.Invoke(new AIChunk
{
Type = AIChunkType.Reasoning,
- Text = text
+ Text = text!
});
}
}
diff --git a/Utils/AiReasoningGuard.cs b/Utils/AiReasoningGuard.cs
new file mode 100644
index 0000000..be913d1
--- /dev/null
+++ b/Utils/AiReasoningGuard.cs
@@ -0,0 +1,181 @@
+using System;
+using System.Collections.Generic;
+
+namespace AnotherReplayReader.Utils
+{
+ ///
+ /// 推理保护策略:截断 reasoning_content,并通过研究验证过的 tool call 历史
+ /// 让模型从已有推理自然收尾。
+ ///
+ internal static class AiReasoningGuard
+ {
+ public const string ToolName = "analysis_hint";
+ public const string TruncationMarker = "[INTERNAL_REASONING_TRUNCATED]";
+ public const string WrapUpStatement =
+ "我已经整理出足够的信息,现在立即结束内部推理,直接输出最终回答。";
+ public const string ToolCallId = "call_reasoning_guard";
+ public const int MinTokenLimit = 4096;
+
+ public static int GetEffectiveTokenLimit(AiProvider provider, AiModel model)
+ {
+ if (model.ReasoningGuardTokenLimit is { } explicitLimit && explicitLimit > 0)
+ {
+ return explicitLimit;
+ }
+
+ return Math.Max(MinTokenLimit, provider.DefaultMaxTokens / 2);
+ }
+
+ public static string BuildToolInstruction()
+ {
+ return "请继续你本次 tool_calls 消息中携带的 reasoning_content。"
+ + "你已经完成了足够的分析,现在进入收尾阶段:"
+ + "立即停止继续展开推理,不要再逐条枚举输入事件,不要再做新的检查,也不要继续“再确认一下”。"
+ + "请直接输出最终结果,包括正文、[机器可读声明] 和 [小结]。"
+ + "如果某些细节不确定,就用证据等级说明。";
+ }
+
+ public static string BuildToolResult()
+ {
+ return "工具结果:" + BuildToolInstruction()
+ + "\n你本次 tool_calls 消息中携带的 reasoning_content 的最后一句已经宣告收尾,"
+ + "请立即执行,不要继续讨论是否还需要分析。"
+ + "\n这份推理内容末尾有内部标记 " + TruncationMarker
+ + ",表示前面的推理已被安全截断。"
+ + "\n如果旧推理中存在未完成的 JSON、JSON 代码块或其他结构性内容,"
+ + "请丢弃其未完成部分,并重新输出完整、格式正确的结果。"
+ + "\n不要复述该标记。";
+ }
+
+ public static object BuildToolDefinition()
+ {
+ return new
+ {
+ type = "function",
+ function = new
+ {
+ name = ToolName,
+ description = "为当前内部推理提供收尾提示。",
+ parameters = new
+ {
+ type = "object",
+ properties = new
+ {
+ instruction = new
+ {
+ type = "string",
+ description = "模型应遵循的收尾提示。"
+ }
+ },
+ required = new[] { "instruction" }
+ }
+ }
+ };
+ }
+
+ ///
+ /// 保留尽可能多的推理前缀,并在末尾追加截断标记与收尾决定。
+ ///
+ public static (string Text, string FormatState) PrepareReasoning(
+ string reasoning,
+ int tokenLimit)
+ {
+ if (string.IsNullOrEmpty(reasoning))
+ {
+ return ("\n\n" + TruncationMarker + "\n\n" + WrapUpStatement, "无格式问题");
+ }
+
+ var maxIndex = FindMaxPrefixIndexByTokens(reasoning, tokenLimit);
+ var boundary = FindSafeBoundary(reasoning, maxIndex);
+ var prefix = reasoning.Substring(0, boundary).TrimEnd();
+ var formatState = DescribeFormatState(prefix);
+ return (
+ prefix + "\n\n" + TruncationMarker + "\n\n" + WrapUpStatement,
+ formatState);
+ }
+
+ public static string DescribeFormatState(string text)
+ {
+ var lines = new List();
+ var codeFenceCount = CountOccurrences(text, "```");
+ if (codeFenceCount % 2 == 1)
+ {
+ lines.Add("代码围栏未闭合");
+ }
+
+ var braceDelta = CountDifference(text, '{', '}');
+ var bracketDelta = CountDifference(text, '[', ']');
+ if (braceDelta > 0 || bracketDelta > 0)
+ {
+ lines.Add("JSON/方括号结构可能未闭合");
+ }
+
+ return lines.Count == 0 ? "无格式问题" : string.Join(";", lines);
+ }
+
+ private static int FindMaxPrefixIndexByTokens(string text, int tokenLimit)
+ {
+ var low = 0;
+ var high = text.Length;
+ while (low < high)
+ {
+ var mid = (low + high + 1) / 2;
+ if (AiContextBudget.EstimateTokens(text.Substring(0, mid)) <= tokenLimit)
+ {
+ low = mid;
+ }
+ else
+ {
+ high = mid - 1;
+ }
+ }
+ return low;
+ }
+
+ private static int FindSafeBoundary(string text, int maxIndex)
+ {
+ const int searchBack = 160;
+ var start = Math.Max(0, maxIndex - searchBack);
+ for (var i = maxIndex - 1; i >= start; --i)
+ {
+ var c = text[i];
+ if (c == '\n' || c == '。' || c == ';' || c == ','
+ || c == ':' || c == ';' || c == '.'
+ || c == ',' || c == ')' || c == '}' || c == ']')
+ {
+ return i + 1;
+ }
+ }
+ return maxIndex;
+ }
+
+ private static int CountOccurrences(string text, string value)
+ {
+ var count = 0;
+ var index = 0;
+ while ((index = text.IndexOf(value, index, StringComparison.Ordinal)) >= 0)
+ {
+ count++;
+ index += value.Length;
+ }
+ return count;
+ }
+
+ private static int CountDifference(string text, char open, char close)
+ {
+ var delta = 0;
+ foreach (var c in text)
+ {
+ if (c == open)
+ {
+ delta++;
+ }
+ else if (c == close)
+ {
+ delta--;
+ }
+ }
+ return delta;
+ }
+ }
+}
diff --git a/Utils/AiSettings.cs b/Utils/AiSettings.cs
index e10fa42..f8b7c85 100644
--- a/Utils/AiSettings.cs
+++ b/Utils/AiSettings.cs
@@ -48,6 +48,17 @@ namespace AnotherReplayReader
///
public int? ContextBudget { get; set; }
+ ///
+ /// 推理保护:累计 reasoning_content 达到阈值时中断流式响应并请求一次续写。
+ /// 默认关闭。
+ ///
+ public bool ReasoningGuardEnabled { get; set; }
+
+ ///
+ /// 推理保护的 token 阈值。null 或 0 使用派生值 max(4096, provider.DefaultMaxTokens / 2)。
+ ///
+ public int? ReasoningGuardTokenLimit { get; set; }
+
public Dictionary ExtraParameters { get; set; } = [];
///