diff --git a/AIChatPanel.xaml.cs b/AIChatPanel.xaml.cs
index ccf4683..8356e49 100644
--- a/AIChatPanel.xaml.cs
+++ b/AIChatPanel.xaml.cs
@@ -10,7 +10,6 @@ using System.Diagnostics;
using System.Diagnostics.Tracing;
using System.Linq;
using System.Text;
-using System.Text.Encodings.Web;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@@ -97,7 +96,6 @@ namespace AnotherReplayReader
// 输出段落
private (Paragraph? Think, Paragraph? Content)? _currentContent;
private Paragraph? _lastContentParagraph;
- private bool _nextThinkingIsContinuation;
// 进度计时
private TimeSpan _previousTotalTime;
@@ -227,7 +225,6 @@ namespace AnotherReplayReader
_thinkBlockWasExpanded = false;
_currentContent = null;
_lastContentParagraph = null;
- _nextThinkingIsContinuation = false;
_previousTotalTime = TimeSpan.Zero;
_previousTotalChars = 0;
@@ -395,7 +392,6 @@ namespace AnotherReplayReader
OnChunk,
_linkedCts.Token);
UpdateTokenDisplay(overviewResult);
- ReportReasoningGuardResult(overviewResult);
_overview = OverviewParser.Parse(overviewResult.Response);
_overviewNarrative = _overview.Narrative;
overviewAttempt++;
@@ -555,7 +551,6 @@ namespace AnotherReplayReader
OnChunk,
_linkedCts.Token);
UpdateTokenDisplay(windowResult);
- ReportReasoningGuardResult(windowResult);
windowResponse = windowResult.Response;
if (backqueryCount >= maxBackqueriesPerSegment)
@@ -649,7 +644,6 @@ namespace AnotherReplayReader
OnChunk,
_linkedCts.Token);
UpdateTokenDisplay(revisionResult);
- ReportReasoningGuardResult(revisionResult);
if (string.IsNullOrWhiteSpace(revisionResult.Response))
{
@@ -760,7 +754,6 @@ namespace AnotherReplayReader
_linkedCts.Token);
UpdateTokenDisplay(result);
- ReportReasoningGuardResult(result);
// 总结轮回查:允许模型请求一次远处原始区间,作为同一会话的追加输入。
if (_replayData is null)
@@ -817,7 +810,6 @@ namespace AnotherReplayReader
OnChunk,
_linkedCts.Token);
UpdateTokenDisplay(finalSummary);
- ReportReasoningGuardResult(finalSummary);
AppendLog("总结完成", "已根据回查区间补充最终总结。", false);
}
@@ -899,22 +891,11 @@ namespace AnotherReplayReader
_chunkQueue.Enqueue((chunk, DateTimeOffset.UtcNow));
}
- private void ReportReasoningGuardResult(AIAnalyze.Result result)
- {
- if (string.IsNullOrWhiteSpace(result.ReasoningContinuationError))
- {
- return;
- }
-
- AppendLog("推理保护警告", result.ReasoningContinuationError, false, _currentStage);
- }
-
private void StartThinkingBlock()
{
_thinkingStartTime = DateTime.Now;
var stage = _currentStage ?? "分析";
- var label = _nextThinkingIsContinuation ? "AI 续写中..." : "AI 思考中...";
- _nextThinkingIsContinuation = false;
+ var label = "AI 思考中...";
var parent = _currentVersionAddBlock is null
? GetStageBlocks(_currentStage)
: null;
@@ -1200,176 +1181,6 @@ namespace AnotherReplayReader
return block.Substring(jsonStart, jsonEnd - jsonStart + 1).Trim();
}
- private void AppendReasoningDiagnostics(string payload)
- {
- try
- {
- using var doc = JsonDocument.Parse(payload);
- var root = doc.RootElement;
- var originalText = root.TryGetProperty("originalRequestJson", out var original)
- && original.ValueKind == JsonValueKind.String
- ? original.GetString()
- : null;
- var continuationText =
- root.TryGetProperty("continuationRequestJson", out var continuation)
- && continuation.ValueKind == JsonValueKind.String
- ? continuation.GetString()
- : null;
-
- var summary = BuildReasoningDiagnosticSummary(
- originalText,
- continuationText);
- if (!string.IsNullOrWhiteSpace(summary))
- {
- AppendLog("推理保护诊断", summary, true, _currentStage);
- }
- if (!string.IsNullOrWhiteSpace(continuationText))
- {
- AppendLog(
- "完整续写请求 JSON",
- PrettyPrintJson(continuationText!),
- true,
- _currentStage);
- }
- }
- catch
- {
- AppendLog("推理保护诊断", payload, true, _currentStage);
- }
- }
-
- private static string BuildReasoningDiagnosticSummary(
- string? originalJson,
- string? continuationJson)
- {
- var sb = new StringBuilder();
- sb.AppendLine($"消息数:{GetMessageCount(originalJson)} → {GetMessageCount(continuationJson)}");
- if (string.IsNullOrWhiteSpace(continuationJson))
- {
- return sb.ToString().TrimEnd();
- }
-
- try
- {
- using var doc = JsonDocument.Parse(continuationJson!);
- if (!doc.RootElement.TryGetProperty("messages", out var messages)
- || messages.ValueKind != JsonValueKind.Array)
- {
- return sb.ToString().TrimEnd();
- }
-
- for (var i = messages.GetArrayLength() - 1; i >= 0; --i)
- {
- var message = messages[i];
- if (!message.TryGetProperty("tool_calls", out var toolCalls)
- || toolCalls.ValueKind != JsonValueKind.Array
- || toolCalls.GetArrayLength() == 0)
- {
- continue;
- }
-
- var toolName = "unknown";
- if (toolCalls[0].TryGetProperty("function", out var function)
- && function.TryGetProperty("name", out var name))
- {
- toolName = name.GetString() ?? toolName;
- }
-
- var reasoning = message.TryGetProperty("reasoning_content", out var reasoningProp)
- ? reasoningProp.GetString()
- : null;
- sb.AppendLine("新增 assistant 消息:");
- sb.AppendLine($" - reasoning_content 长度:{reasoning?.Length ?? 0}");
- if (!string.IsNullOrEmpty(reasoning))
- {
- var markerIndex = reasoning.IndexOf(
- AiReasoningGuard.TruncationMarker,
- StringComparison.Ordinal);
- var tail = markerIndex >= 0
- ? reasoning.Substring(markerIndex)
- : "(未找到截断标记)";
- sb.AppendLine($" - 末尾追加:{Excerpt(tail, 160)}");
- }
- sb.AppendLine($" - tool_calls:{toolName}");
- break;
- }
-
- for (var i = messages.GetArrayLength() - 1; i >= 0; --i)
- {
- var message = messages[i];
- if (!message.TryGetProperty("role", out var role)
- || role.GetString() != "tool")
- {
- continue;
- }
-
- var toolCallId = message.TryGetProperty("tool_call_id", out var id)
- ? id.GetString()
- : "?";
- var content = message.TryGetProperty("content", out var contentProp)
- ? contentProp.GetString()
- : null;
- sb.AppendLine("新增 tool 消息:");
- sb.AppendLine($" - tool_call_id:{toolCallId}");
- sb.AppendLine($" - 指令开头:{Excerpt(content ?? "(空)", 160)}");
- break;
- }
- }
- catch
- {
- // 摘要解析失败时保留原始 JSON 回退。
- }
-
- return sb.ToString().TrimEnd();
- }
-
- private static int GetMessageCount(string? requestJson)
- {
- if (string.IsNullOrWhiteSpace(requestJson))
- {
- return 0;
- }
- try
- {
- using var doc = JsonDocument.Parse(requestJson!);
- return doc.RootElement.TryGetProperty("messages", out var messages)
- && messages.ValueKind == JsonValueKind.Array
- ? messages.GetArrayLength()
- : 0;
- }
- catch
- {
- return 0;
- }
- }
-
- private static string PrettyPrintJson(string json)
- {
- try
- {
- using var doc = JsonDocument.Parse(json);
- return JsonSerializer.Serialize(
- doc.RootElement,
- new JsonSerializerOptions
- {
- WriteIndented = true,
- Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
- });
- }
- catch
- {
- return json;
- }
- }
-
- private static string Excerpt(string text, int maxLength)
- {
- var normalized = text.Replace("\r", "").Replace("\n", " ");
- return normalized.Length <= maxLength
- ? normalized
- : normalized.Substring(0, maxLength) + "…";
- }
-
private void FlushPendingUiActions()
{
_isFlushingPendingUiActions = true;
@@ -1592,7 +1403,6 @@ 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;
@@ -1620,19 +1430,6 @@ namespace AnotherReplayReader
{
errorSb.AppendLine(chunk.Text);
}
- else if (chunk.Type == AIAnalyze.AIChunkType.ReasoningGuard)
- {
- EndThinkingBlock();
- _nextThinkingIsContinuation = true;
- _pendingUiActions.Enqueue(() =>
- AppendLog("推理保护", chunk.Text, false, _currentStage));
- guardTriggered = true;
- }
- else if (chunk.Type == AIAnalyze.AIChunkType.ReasoningGuardRequest)
- {
- _pendingUiActions.Enqueue(() =>
- AppendReasoningDiagnostics(chunk.Text));
- }
}
if (thinkSb.Length > 0)
@@ -1651,10 +1448,6 @@ namespace AnotherReplayReader
{
AddErrorBlock(errorSb.ToString());
}
- if (guardTriggered)
- {
- _currentContent = null;
- }
}
private void AutoScroll()
diff --git a/AIProviderSettingsControl.xaml b/AIProviderSettingsControl.xaml
index 74bb221..77c6b9d 100644
--- a/AIProviderSettingsControl.xaml
+++ b/AIProviderSettingsControl.xaml
@@ -1,4 +1,4 @@
-
-
@@ -165,34 +164,20 @@
Content="支持 SSE 流式输出"
Margin="0,5"/>
-
-
-
-
-
-
-
-
+
-
+
diff --git a/AIProviderSettingsControl.xaml.cs b/AIProviderSettingsControl.xaml.cs
index d7c7b35..2dd2961 100644
--- a/AIProviderSettingsControl.xaml.cs
+++ b/AIProviderSettingsControl.xaml.cs
@@ -311,11 +311,6 @@ 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(
@@ -370,8 +365,6 @@ namespace AnotherReplayReader
ContextLength = known.ContextLength,
ContextBudget = known.ContextBudget,
IsStream = known.IsStream,
- ReasoningGuardEnabled = known.ReasoningGuardEnabled,
- ReasoningGuardTokenLimit = known.ReasoningGuardTokenLimit,
ExtraParameters = new Dictionary(known.ExtraParameters)
});
}
@@ -474,16 +467,6 @@ 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
{
@@ -529,8 +512,6 @@ 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()
@@ -550,8 +531,6 @@ 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 d42a183..9c73f0a 100644
--- a/AI_REASONING_CONTINUATION_RESEARCH.md
+++ b/AI_REASONING_CONTINUATION_RESEARCH.md
@@ -2,7 +2,7 @@
日期:2026-08-23
范围:OpenCodeGo / `deepseek-v4-flash`,OpenAI 兼容 `/chat/completions`
-状态:实验性研究,未集成到主项目
+状态:实验性研究,未集成到主项目;主项目曾短暂集成推理保护,后因实测影响输出质量而移除(详见 `PLAN_ai_analysis_v2.md`)
## 摘要
@@ -10,7 +10,7 @@
结论是:**仅发送 `reasoning_content` 或“上一轮思维链”并不稳定;成功率最高的方案是伪造一段 tool call 历史,并把续写要求、输出格式和需要引用的内容放入 tool 结果中。**
-与本研究相关的代码在 `AiV2.Tests/Program.cs` 中,默认不会执行。
+与本研究相关的实验代码已从 `AiV2.Tests/Program.cs` 中移除,不再保留可执行实验入口。
## 目标
@@ -232,9 +232,11 @@ system
- 不要假设 `reasoning_content` 的末尾内容一定能被模型召回。
- 增加原始响应/请求日志,以便区分“模型未看到”和“模型看到了但没引用”。
-## 主项目集成状态(2026-08-23)
+## 主项目集成状态(2026-08-23,历史记录;该功能已全部移除)
-- 主项目已落地模型级“推理保护”实验开关,默认关闭,仅在 `IsStream=true` 的模型上生效。
+> 以下内容是当时的集成状态,不代表当前代码。当前主项目已移除推理保护及所有相关代码/测试。
+
+- 主项目曾落地模型级“推理保护”实验开关,默认关闭,仅在 `IsStream=true` 的模型上生效。
- 累计推理 token 达到阈值后停止读取当前 SSE 响应,保留部分推理和正文;最多发起一次续写。
- 续写采用本报告推荐的伪造 tool call 历史:`assistant(reasoning_content + tool_calls)` → `tool(tool_call_id + 收尾指令)`。
- 首请求不携带 `tools`,只有续写请求注入 `tools` 与 `tool_choice=none`;缓存命中为 best-effort。
@@ -248,9 +250,7 @@ system
## 专用测试状态
-- 测试代码:`AiV2.Tests/Program.cs` 中的 `OpenCodeGoFakeToolCallTests`。
-- 默认行为:不执行。
-- 启用条件:`ARR_AI_E2E=1` 且 `ARR_AI_E2E_TOOL=1`。
-- 输出:Markdown 报告,路径通过 `ARR_AI_TOOL_REPORT_PATH` 指定。
+- 原测试代码:`AiV2.Tests/Program.cs` 中的 `OpenCodeGoFakeToolCallTests`。
+- 现状:已随推理保护一起移除,不再保留可执行实验入口。
主项目中的实验性 `ChatMessage` 扩展、`AiReasoningContinuationSettings`、UI 开关、回传/降级逻辑和 tool call 历史构造均已移除。
diff --git a/AiV2.Tests/Program.cs b/AiV2.Tests/Program.cs
index 6320444..2f94b34 100644
--- a/AiV2.Tests/Program.cs
+++ b/AiV2.Tests/Program.cs
@@ -46,9 +46,6 @@ 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();
@@ -992,46 +989,12 @@ namespace AiV2.Tests
[property: JsonPropertyName("content"),
JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Content = null,
[property: JsonPropertyName("reasoning_content"),
- JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? ReasoningContent = null,
- [property: JsonPropertyName("tool_calls"),
- JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] IReadOnlyList? ToolCalls = null,
- [property: JsonPropertyName("tool_call_id"),
- JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? ToolCallId = null)
+ JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? ReasoningContent = null)
{
- internal sealed record ToolCall(
- [property: JsonPropertyName("id")] string Id,
- [property: JsonPropertyName("type")] string Type,
- [property: JsonPropertyName("function")] ToolCallFunction Function);
-
- internal sealed record ToolCallFunction(
- [property: JsonPropertyName("name")] string Name,
- [property: JsonPropertyName("arguments")] string Arguments);
-
internal static ChatMessage Assistant(string? content, string? reasoningContent = null) =>
new("assistant",
string.IsNullOrEmpty(content) ? null : content,
string.IsNullOrEmpty(reasoningContent) ? null : reasoningContent);
-
- internal static ChatMessage AssistantToolCall(
- string? reasoningContent,
- string toolCallId,
- string toolName,
- string arguments) =>
- new(
- "assistant",
- null,
- reasoningContent,
- new[]
- {
- new ChatMessage.ToolCall(
- toolCallId,
- "function",
- new ChatMessage.ToolCallFunction(toolName, arguments))
- },
- null);
-
- internal static ChatMessage ToolResult(string toolCallId, string content) =>
- new("tool", content, null, null, toolCallId);
}
public async Task CompleteAsync(
@@ -1189,771 +1152,6 @@ 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 =
- "你是用于验证思维链回传的实验辅助。请先用 reasoning_content 进行内部推理,再给出简短、准确的回答。";
- private const string FirstUserPrompt =
- "请分析:A 比 B 高 20%,B 比 C 高 25%,那么 A 比 C 高多少?请先思考,再回答最终百分比。";
- private const string ToolName = "analysis_hint";
- private const string MemoryMarker = "TOKEN_MARK=731942";
-
- public static void Run()
- {
- if (Environment.GetEnvironmentVariable("ARR_AI_E2E") != "1"
- || Environment.GetEnvironmentVariable("ARR_AI_E2E_TOOL") != "1")
- {
- Console.WriteLine(
- " [跳过] 未同时设置 ARR_AI_E2E=1 与 ARR_AI_E2E_TOOL=1,"
- + "跳过伪造 tool call 历史实验");
- return;
- }
- RunAsync().GetAwaiter().GetResult();
- }
-
- private static async Task RunAsync()
- {
- var settingsPath = FindSettingsPath();
- Console.WriteLine($" [E2E-TOOL] 配置:{settingsPath}");
-
- 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 (string.IsNullOrWhiteSpace(provider.ApiKey))
- {
- throw new InvalidOperationException("OpenCodeGo 的 API Key 为空");
- }
-
- Console.WriteLine(
- $" [E2E-TOOL] Provider={provider.Name}; Model={model.ModelId}; "
- + $"BaseUrl={provider.BaseUrl}; Stream={model.IsStream}; ApiKey=***");
-
- var report = new StringBuilder();
- AppendHeader(report, provider, model, settingsPath);
- var reportPath = Environment.GetEnvironmentVariable("ARR_AI_TOOL_REPORT_PATH");
- if (string.IsNullOrWhiteSpace(reportPath))
- {
- reportPath = Path.Combine(
- Environment.CurrentDirectory, "AI_reasoning_continuation_position_sweep_report.md");
- }
- reportPath = Path.GetFullPath(reportPath);
-
- try
- {
- var analyzer = new AIAnalyze();
- var normalContext = new AiRequestContext(provider, model);
- var firstMessages = ImmutableList.Empty
- .Add(new AIAnalyze.ChatMessage("system", SystemPrompt))
- .Add(new AIAnalyze.ChatMessage("user", FirstUserPrompt));
- var first = await CompleteAndRecordAsync(
- analyzer, normalContext, firstMessages, "初始请求(system + user)", report);
- var firstReasoning = first?.Reasoning ?? string.Empty;
- if (first is null || firstReasoning.Length == 0)
- {
- report.AppendLine("初始请求未获得 reasoning_content,无法继续伪造 tool call 实验。");
- return;
- }
- var initialFirst = first;
-
- var toolContext = BuildToolContext(provider, model);
- var wordings = new[]
- {
- new
- {
- Name = "措辞B:当前推理",
- Target = "你当前正在进行的内部推理",
- ArgumentInstruction = "继续你当前正在进行的内部推理,并参考工具结果。"
- },
- new
- {
- Name = "措辞D:tool_calls 携带的 reasoning_content",
- Target = "你本次 tool_calls 消息中携带的 reasoning_content",
- ArgumentInstruction = "继续你本次 tool_calls 消息中携带的 reasoning_content,并参考工具结果。"
- }
- };
- var positions = new[]
- {
- new { Name = "25%", Ratio = 0.25 },
- new { Name = "50%", Ratio = 0.50 },
- new { Name = "75%", Ratio = 0.75 },
- new { Name = "末尾", Ratio = 1.0 }
- };
- const int repetitions = 5;
- var combinedCount = wordings.Length * positions.Length;
- var foundCounts = new int[combinedCount];
- var missingCounts = new int[combinedCount];
- var failedCounts = new int[combinedCount];
- var names = new string[combinedCount];
- var markerInSent = new bool[combinedCount];
- var comboIndex = 0;
-
- foreach (var wording in wordings)
- {
- foreach (var position in positions)
- {
- var comboName = $"{wording.Name} / 标记位置 {position.Name}";
- names[comboIndex] = comboName;
- var reasoningWithMarker = InsertMemoryMarker(
- firstReasoning, position.Ratio);
- markerInSent[comboIndex] = reasoningWithMarker.Contains("731942");
- Console.WriteLine(
- $" [E2E-TOOL] 正在测试:{comboName}(重复 5 次)");
- for (var rep = 0; rep < repetitions; ++rep)
- {
- var toolCallId = $"call_position_{comboIndex + 1}_{rep + 1}";
- var arguments = JsonSerializer.Serialize(new
- {
- instruction = wording.ArgumentInstruction
- });
- var messages = ImmutableList.Empty
- .Add(new AIAnalyze.ChatMessage("system", SystemPrompt))
- .Add(new AIAnalyze.ChatMessage("user", FirstUserPrompt))
- .Add(AIAnalyze.ChatMessage.AssistantToolCall(
- reasoningWithMarker, toolCallId, ToolName, arguments))
- .Add(AIAnalyze.ChatMessage.ToolResult(
- toolCallId, BuildToolResult(wording.Target)));
- var result = await CompleteAndRecordAsync(
- analyzer, toolContext, messages,
- $"记忆标记 {comboName}(重复 {rep + 1}/{repetitions})", report);
- if (result is { } completed)
- {
- var combined = (completed.Reasoning ?? string.Empty)
- + "\n" + completed.Response;
- var found = ContainsMemoryMarker(combined);
- var missing = SaysMarkerMissing(combined);
- if (found)
- {
- foundCounts[comboIndex]++;
- }
- if (missing)
- {
- missingCounts[comboIndex]++;
- }
- Console.WriteLine(
- $" [E2E-TOOL] {comboName} [{rep + 1}/{repetitions}]:"
- + $"content={completed.Response.Length};"
- + $"reasoning={(completed.Reasoning ?? string.Empty).Length};"
- + $"markerFound={found};missing={missing}");
- }
- else
- {
- failedCounts[comboIndex]++;
- Console.WriteLine(
- $" [E2E-TOOL] {comboName} [{rep + 1}/{repetitions}]:请求失败(详见报告)");
- }
- }
- comboIndex++;
- }
- }
- AppendSummary(
- report,
- names,
- markerInSent,
- foundCounts,
- missingCounts,
- failedCounts);
- }
- finally
- {
- var reportDir = Path.GetDirectoryName(reportPath);
- if (!string.IsNullOrEmpty(reportDir))
- {
- Directory.CreateDirectory(reportDir);
- }
- File.WriteAllText(reportPath, report.ToString(), new UTF8Encoding(false));
- Console.WriteLine($" [E2E-TOOL] 人工审阅报告已写入:{reportPath}");
- }
- }
-
- private static AiRequestContext BuildToolContext(
- AiProvider provider,
- AiModel sourceModel)
- {
- var model = new AiModel
- {
- ModelId = sourceModel.ModelId,
- DisplayName = sourceModel.DisplayName,
- IsStream = sourceModel.IsStream,
- ContextLength = sourceModel.ContextLength,
- ContextBudget = sourceModel.ContextBudget,
- ExtraParameters = new Dictionary(sourceModel.ExtraParameters)
- };
- model.ExtraParameters["tools"] = new[]
- {
- new
- {
- type = "function",
- function = new
- {
- name = ToolName,
- description = "向模型提供上一轮思维链的继续提示。",
- parameters = new
- {
- type = "object",
- properties = new
- {
- instruction = new
- {
- type = "string",
- description = "模型应遵循的续写提示。"
- }
- },
- required = new[] { "instruction" }
- }
- }
- }
- };
- model.ExtraParameters["tool_choice"] = "none";
- return new AiRequestContext(provider, model);
- }
-
- private static string BuildToolResult(string target) =>
- "工具结果:请继续" + target + "。这是一次确定性记忆测试。"
- + "该思考内容中有一个内部记忆标记,请不要从本工具结果中猜测或寻找。"
- + "请原样写出该标记;如果该思考内容中没有该标记,请明确回答“标记不存在”。"
- + "不要从零重新推导,也不要杜撰标记。";
-
- private static string InsertMemoryMarker(string text, double ratio)
- {
- if (ratio >= 1)
- {
- return text + "\n\n[内部记忆标记 " + MemoryMarker + "]";
- }
- var index = Math.Max(
- 0,
- Math.Min(text.Length, (int)(text.Length * ratio)));
- return text.Insert(
- index,
- "\n\n[内部记忆标记 " + MemoryMarker + "]\n\n");
- }
-
- private static bool ContainsMemoryMarker(string text) =>
- text.Contains("731942") || text.Contains("TOKEN_MARK");
-
- private static bool SaysMarkerMissing(string text) =>
- text.Contains("标记不存在")
- || text.Contains("没有该标记")
- || text.Contains("没有标记");
-
- private static void AppendSummary(
- StringBuilder report,
- string[] names,
- bool[] markerInSent,
- int[] foundCounts,
- int[] missingCounts,
- int[] failedCounts)
- {
- report.AppendLine("## 记忆标记统计");
- report.AppendLine();
- report.AppendLine($"- 记忆标记:`{MemoryMarker}`");
- report.AppendLine("- 每个组合重复 5 次。");
- report.AppendLine();
- for (var i = 0; i < names.Length; ++i)
- {
- report.AppendLine(
- $"- **{names[i]}**:发送链包含标记 = {markerInSent[i]};"
- + $"正确写出标记 = {foundCounts[i]}/5;"
- + $"明确说没有 = {missingCounts[i]}/5;"
- + $"请求失败 = {failedCounts[i]}/5");
- }
- report.AppendLine();
- }
-
- private static async Task CompleteAndRecordAsync(
- AIAnalyze analyzer,
- AiRequestContext context,
- ImmutableList messages,
- string label,
- StringBuilder report)
- {
- var sw = Stopwatch.StartNew();
- try
- {
- var result = await analyzer.CompleteAsync(
- messages, context, null, CancellationToken.None);
- sw.Stop();
- AppendRecord(report, label, messages, result, sw.Elapsed);
- return result;
- }
- catch (Exception ex)
- {
- sw.Stop();
- report.AppendLine($"## {label}(请求失败)");
- report.AppendLine();
- report.AppendLine($"- 耗时:{sw.Elapsed.TotalSeconds:0.00} 秒");
- report.AppendLine($"- 错误:{ex.Message}");
- report.AppendLine("- 关键点:assistant 消息包含 tool_calls,随后有 tool 回复;请求携带 tools 定义。");
- report.AppendLine();
- report.AppendLine("---");
- report.AppendLine();
- Console.WriteLine($" [E2E-TOOL] {label} 失败:{ex.Message}");
- return null;
- }
- }
-
- private static void AppendHeader(
- StringBuilder report,
- AiProvider provider,
- AiModel model,
- string settingsPath)
- {
- report.AppendLine("# 伪造 tool call 历史实验");
- report.AppendLine();
- report.AppendLine($"- 配置:{provider.Name} / {model.ModelId}");
- report.AppendLine($"- BaseUrl:{provider.BaseUrl}");
- report.AppendLine($"- 配置文件:{settingsPath}");
- report.AppendLine("- API Key:***(不写入报告)");
- report.AppendLine();
- report.AppendLine("本实验在请求中声明了 `tools` 并将 `tool_choice` 设为 `none`;");
- report.AppendLine("构造的历史为:system → 初始 user → assistant(reasoning_content + tool_calls) → tool(tool_call_id + 提示结果)。");
- report.AppendLine(
- $"完整思维链按 25%/50%/75%/末尾四种位置注入确定性记忆标记 `{MemoryMarker}`;"
- + "工具结果使用“当前推理”和“本次 tool_calls 消息携带的 reasoning_content”两种措辞,"
- + "且不提及“上一轮”“截断点”等概念,要求模型原样写出标记。");
- report.AppendLine("没有第 5 条新的 user 消息。");
- report.AppendLine();
- }
-
- private static void AppendRecord(
- StringBuilder report,
- string label,
- ImmutableList messages,
- AIAnalyze.Result result,
- TimeSpan elapsed)
- {
- report.AppendLine($"## {label}");
- report.AppendLine();
- report.AppendLine($"- 耗时:{elapsed.TotalSeconds:0.00} 秒");
- report.AppendLine($"- prompt_tokens:{result.PromptTokens?.ToString() ?? "?"}");
- report.AppendLine($"- completion_tokens:{result.CompletionTokens?.ToString() ?? "?"}");
- report.AppendLine($"- reasoning_tokens:{result.ReasoningTokens?.ToString() ?? "?"}");
- report.AppendLine();
- report.AppendLine("### 发送的 messages");
- report.AppendLine();
- for (var i = 0; i < messages.Count; ++i)
- {
- var message = messages[i];
- report.AppendLine($"**{i + 1}. role = `{message.Role}`**");
- report.AppendLine();
- if (message.Content is null)
- {
- report.AppendLine("content:`未发送`");
- }
- else
- {
- report.AppendLine("content:");
- AppendTextBlock(report, message.Content);
- }
- report.AppendLine();
- if (message.ToolCallId is not null)
- {
- report.AppendLine($"tool_call_id:`{message.ToolCallId}`");
- report.AppendLine();
- }
- if (message.ToolCalls is { } toolCalls)
- {
- report.AppendLine("tool_calls:");
- foreach (var toolCall in toolCalls)
- {
- report.AppendLine(
- $"- id=`{toolCall.Id}`; type=`{toolCall.Type}`; "
- + $"name=`{toolCall.Function.Name}`");
- report.AppendLine();
- report.AppendLine("arguments:");
- AppendTextBlock(report, toolCall.Function.Arguments);
- }
- report.AppendLine();
- }
- if (message.Role == "assistant")
- {
- report.AppendLine(
- message.ReasoningContent is null
- ? "reasoning_content:`未发送`"
- : "reasoning_content:");
- if (message.ReasoningContent is not null)
- {
- AppendTextBlock(report, message.ReasoningContent);
- }
- report.AppendLine();
- }
- report.AppendLine();
- }
- report.AppendLine("### 模型响应");
- report.AppendLine();
- report.AppendLine("新 reasoning_content:");
- AppendTextBlock(report, result.Reasoning ?? string.Empty);
- report.AppendLine();
- report.AppendLine("回答正文(content):");
- AppendTextBlock(report, result.Response);
- report.AppendLine();
- report.AppendLine("---");
- report.AppendLine();
- }
-
- private static void AppendTextBlock(StringBuilder report, string? text)
- {
- report.AppendLine("```text");
- report.AppendLine(string.IsNullOrEmpty(text) ? "(空)" : text!);
- report.AppendLine("```");
- }
-
- internal static string FindSettingsPath()
- {
- var explicitPath = Environment.GetEnvironmentVariable("ARR_AI_SETTINGS_PATH");
- if (!string.IsNullOrWhiteSpace(explicitPath) && File.Exists(explicitPath))
- {
- return explicitPath;
- }
-
- var dir = new DirectoryInfo(AppContext.BaseDirectory);
- for (var i = 0; i < 6 && dir is not null; ++i)
- {
- var candidate = Path.Combine(
- dir.FullName, "bin", "Debug", "net461", "AnotherReplayReader.ai_settings.json");
- if (File.Exists(candidate))
- {
- return candidate;
- }
- var directCandidate = Path.Combine(
- dir.FullName, "AnotherReplayReader.ai_settings.json");
- if (File.Exists(directCandidate))
- {
- return directCandidate;
- }
- dir = dir.Parent;
- }
- throw new InvalidOperationException(
- "未找到 AnotherReplayReader.ai_settings.json;可用 ARR_AI_SETTINGS_PATH 指定");
- }
- }
-
- 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 484db8e..10976c4 100644
--- a/PLAN_ai_analysis_v2.md
+++ b/PLAN_ai_analysis_v2.md
@@ -6,6 +6,13 @@
- 版本:v2.2。WIP/CONTEXT/ADR 旧文档已删除;思维链回传实验单独记录在 `AI_REASONING_CONTINUATION_RESEARCH.md`。
- 关联文档:[AI_REASONING_CONTINUATION_RESEARCH.md](AI_REASONING_CONTINUATION_RESEARCH.md)
+## 实施状态修订(推理保护已移除)
+
+- 实测表明:截断/修改 `reasoning_content` 并伪造 tool call 续写会影响输出质量,已取消该方案。
+- 当前代码不再包含 `AiReasoningGuard`、`ReasoningGuardEnabled`/`ReasoningGuardTokenLimit` 设置、推理保护 UI、`ReasoningGuard` 测试或相关 E2E。
+- 仍保留对模型返回的 `reasoning_content` 的流式展示与 usage 统计;但不会截断、改写、回传或让它触发额外请求。
+- 解决长思考的策略改为:提高输出 token 上限 + 让模型专注更短的时间范围(段内焦点窗口),同时仍提供尽量长的切片上下文并鼓励跨时间关联。
+
## 实施状态修订(2026-08-24 段内焦点窗口)
- 新增「段内焦点窗口」设计:**数据层尽量宽**(整段机械切片按上下文上限提供),**注意力层聚焦窄时间窗**(每个切片再切分为若干分析窗口,每轮一个窗口作为重点)。
@@ -62,7 +69,7 @@
- `AiV2.Tests` 仅保留 `OpenCodeGoFakeToolCallE2e` 专用测试:默认跳过,需同时设置 `ARR_AI_E2E=1` 与 `ARR_AI_E2E_TOOL=1`。
- 实验结果、推荐方案与数据表格见 `AI_REASONING_CONTINUATION_RESEARCH.md`。
-### 2026-08-23 推理保护实现
+### 2026-08-23 推理保护实现(历史记录,后续已移除)
- 主项目已实现默认关闭、模型级配置的推理保护:累计 `reasoning_content` 达到阈值后中断流式响应,并通过研究验证的 tool call 载体请求一次续写。
- 续写只在保留推理末尾依次追加 `[INTERNAL_REASONING_TRUNCATED]` 与中文收尾句,不插入中间 checkpoint;首请求不携带 `tools`,续写请求才注入工具定义和 `tool_choice=none`。
diff --git a/Utils/AIAnalyze.cs b/Utils/AIAnalyze.cs
index 5006c16..ebaef1e 100644
--- a/Utils/AIAnalyze.cs
+++ b/Utils/AIAnalyze.cs
@@ -7,7 +7,6 @@ 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;
@@ -26,48 +25,13 @@ namespace AnotherReplayReader.Utils
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)
+ Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] string? ReasoningContent = 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(
@@ -1277,8 +1241,6 @@ PlayerA: 开始出兵
public enum AIChunkType
{
Reasoning,
- ReasoningGuard,
- ReasoningGuardRequest,
Content,
Error,
Json
@@ -1294,13 +1256,6 @@ PlayerA: 开始出兵
{
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;
@@ -1329,226 +1284,19 @@ PlayerA: 开始出兵
Action? onChunk,
CancellationToken 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),
+ return await Task.Run(
+ () => DoRequest(_http, messages, requestContext, onChunk, cancellationToken),
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;
- }
-
- onChunk?.Invoke(new AIChunk
- {
- Type = AIChunkType.ReasoningGuardRequest,
- Text = JsonSerializer.Serialize(
- new
- {
- originalRequestJson,
- continuationRequestJson
- },
- new JsonSerializerOptions
- {
- Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
- })
- });
-
- 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)
+ AiRequestContext context)
{
var requestParams = ProcessRequestParams(
messages,
context.BuildRequestParams());
- return JsonSerializer.Serialize(
- requestParams,
- writeIndented
- ? new JsonSerializerOptions
- {
- WriteIndented = true,
- Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
- }
- : null);
+ return JsonSerializer.Serialize(requestParams);
}
private static async Task DoRequest(
@@ -1556,8 +1304,7 @@ PlayerA: 开始出兵
ImmutableList messages,
AiRequestContext requestContext,
Action? onChunk,
- CancellationToken cancellationToken,
- int? reasoningTokenLimit = null)
+ CancellationToken cancellationToken)
{
var provider = requestContext.Provider;
var isStream = requestContext.Model.IsStream;
@@ -1579,8 +1326,6 @@ PlayerA: 开始出兵
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
@@ -1625,20 +1370,6 @@ PlayerA: 开始出兵
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
@@ -1656,14 +1387,6 @@ PlayerA: 开始出兵
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)
{
diff --git a/Utils/AiReasoningGuard.cs b/Utils/AiReasoningGuard.cs
deleted file mode 100644
index be913d1..0000000
--- a/Utils/AiReasoningGuard.cs
+++ /dev/null
@@ -1,181 +0,0 @@
-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 5278b1c..0c95489 100644
--- a/Utils/AiSettings.cs
+++ b/Utils/AiSettings.cs
@@ -48,17 +48,6 @@ 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; } = [];
///