remove reasoning guard
This commit is contained in:
+1
-208
@@ -10,7 +10,6 @@ using System.Diagnostics;
|
|||||||
using System.Diagnostics.Tracing;
|
using System.Diagnostics.Tracing;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Encodings.Web;
|
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
@@ -97,7 +96,6 @@ namespace AnotherReplayReader
|
|||||||
// 输出段落
|
// 输出段落
|
||||||
private (Paragraph? Think, Paragraph? Content)? _currentContent;
|
private (Paragraph? Think, Paragraph? Content)? _currentContent;
|
||||||
private Paragraph? _lastContentParagraph;
|
private Paragraph? _lastContentParagraph;
|
||||||
private bool _nextThinkingIsContinuation;
|
|
||||||
|
|
||||||
// 进度计时
|
// 进度计时
|
||||||
private TimeSpan _previousTotalTime;
|
private TimeSpan _previousTotalTime;
|
||||||
@@ -227,7 +225,6 @@ namespace AnotherReplayReader
|
|||||||
_thinkBlockWasExpanded = false;
|
_thinkBlockWasExpanded = false;
|
||||||
_currentContent = null;
|
_currentContent = null;
|
||||||
_lastContentParagraph = null;
|
_lastContentParagraph = null;
|
||||||
_nextThinkingIsContinuation = false;
|
|
||||||
|
|
||||||
_previousTotalTime = TimeSpan.Zero;
|
_previousTotalTime = TimeSpan.Zero;
|
||||||
_previousTotalChars = 0;
|
_previousTotalChars = 0;
|
||||||
@@ -395,7 +392,6 @@ namespace AnotherReplayReader
|
|||||||
OnChunk,
|
OnChunk,
|
||||||
_linkedCts.Token);
|
_linkedCts.Token);
|
||||||
UpdateTokenDisplay(overviewResult);
|
UpdateTokenDisplay(overviewResult);
|
||||||
ReportReasoningGuardResult(overviewResult);
|
|
||||||
_overview = OverviewParser.Parse(overviewResult.Response);
|
_overview = OverviewParser.Parse(overviewResult.Response);
|
||||||
_overviewNarrative = _overview.Narrative;
|
_overviewNarrative = _overview.Narrative;
|
||||||
overviewAttempt++;
|
overviewAttempt++;
|
||||||
@@ -555,7 +551,6 @@ namespace AnotherReplayReader
|
|||||||
OnChunk,
|
OnChunk,
|
||||||
_linkedCts.Token);
|
_linkedCts.Token);
|
||||||
UpdateTokenDisplay(windowResult);
|
UpdateTokenDisplay(windowResult);
|
||||||
ReportReasoningGuardResult(windowResult);
|
|
||||||
windowResponse = windowResult.Response;
|
windowResponse = windowResult.Response;
|
||||||
|
|
||||||
if (backqueryCount >= maxBackqueriesPerSegment)
|
if (backqueryCount >= maxBackqueriesPerSegment)
|
||||||
@@ -649,7 +644,6 @@ namespace AnotherReplayReader
|
|||||||
OnChunk,
|
OnChunk,
|
||||||
_linkedCts.Token);
|
_linkedCts.Token);
|
||||||
UpdateTokenDisplay(revisionResult);
|
UpdateTokenDisplay(revisionResult);
|
||||||
ReportReasoningGuardResult(revisionResult);
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(revisionResult.Response))
|
if (string.IsNullOrWhiteSpace(revisionResult.Response))
|
||||||
{
|
{
|
||||||
@@ -760,7 +754,6 @@ namespace AnotherReplayReader
|
|||||||
_linkedCts.Token);
|
_linkedCts.Token);
|
||||||
|
|
||||||
UpdateTokenDisplay(result);
|
UpdateTokenDisplay(result);
|
||||||
ReportReasoningGuardResult(result);
|
|
||||||
|
|
||||||
// 总结轮回查:允许模型请求一次远处原始区间,作为同一会话的追加输入。
|
// 总结轮回查:允许模型请求一次远处原始区间,作为同一会话的追加输入。
|
||||||
if (_replayData is null)
|
if (_replayData is null)
|
||||||
@@ -817,7 +810,6 @@ namespace AnotherReplayReader
|
|||||||
OnChunk,
|
OnChunk,
|
||||||
_linkedCts.Token);
|
_linkedCts.Token);
|
||||||
UpdateTokenDisplay(finalSummary);
|
UpdateTokenDisplay(finalSummary);
|
||||||
ReportReasoningGuardResult(finalSummary);
|
|
||||||
AppendLog("总结完成", "已根据回查区间补充最终总结。", false);
|
AppendLog("总结完成", "已根据回查区间补充最终总结。", false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -899,22 +891,11 @@ namespace AnotherReplayReader
|
|||||||
_chunkQueue.Enqueue((chunk, DateTimeOffset.UtcNow));
|
_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()
|
private void StartThinkingBlock()
|
||||||
{
|
{
|
||||||
_thinkingStartTime = DateTime.Now;
|
_thinkingStartTime = DateTime.Now;
|
||||||
var stage = _currentStage ?? "分析";
|
var stage = _currentStage ?? "分析";
|
||||||
var label = _nextThinkingIsContinuation ? "AI 续写中..." : "AI 思考中...";
|
var label = "AI 思考中...";
|
||||||
_nextThinkingIsContinuation = false;
|
|
||||||
var parent = _currentVersionAddBlock is null
|
var parent = _currentVersionAddBlock is null
|
||||||
? GetStageBlocks(_currentStage)
|
? GetStageBlocks(_currentStage)
|
||||||
: null;
|
: null;
|
||||||
@@ -1200,176 +1181,6 @@ namespace AnotherReplayReader
|
|||||||
return block.Substring(jsonStart, jsonEnd - jsonStart + 1).Trim();
|
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()
|
private void FlushPendingUiActions()
|
||||||
{
|
{
|
||||||
_isFlushingPendingUiActions = true;
|
_isFlushingPendingUiActions = true;
|
||||||
@@ -1592,7 +1403,6 @@ namespace AnotherReplayReader
|
|||||||
var thinkSb = new StringBuilder();
|
var thinkSb = new StringBuilder();
|
||||||
var contentSb = new StringBuilder();
|
var contentSb = new StringBuilder();
|
||||||
var errorSb = new StringBuilder();
|
var errorSb = new StringBuilder();
|
||||||
var guardTriggered = false;
|
|
||||||
while (_chunkQueue.TryDequeue(out var data))
|
while (_chunkQueue.TryDequeue(out var data))
|
||||||
{
|
{
|
||||||
var (chunk, time) = data;
|
var (chunk, time) = data;
|
||||||
@@ -1620,19 +1430,6 @@ namespace AnotherReplayReader
|
|||||||
{
|
{
|
||||||
errorSb.AppendLine(chunk.Text);
|
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)
|
if (thinkSb.Length > 0)
|
||||||
@@ -1651,10 +1448,6 @@ namespace AnotherReplayReader
|
|||||||
{
|
{
|
||||||
AddErrorBlock(errorSb.ToString());
|
AddErrorBlock(errorSb.ToString());
|
||||||
}
|
}
|
||||||
if (guardTriggered)
|
|
||||||
{
|
|
||||||
_currentContent = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void AutoScroll()
|
private void AutoScroll()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<UserControl x:Class="AnotherReplayReader.AIProviderSettingsControl"
|
<UserControl x:Class="AnotherReplayReader.AIProviderSettingsControl"
|
||||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||||
@@ -143,7 +143,6 @@
|
|||||||
<RowDefinition Height="*"/>
|
<RowDefinition Height="*"/>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
<RowDefinition Height="Auto"/>
|
<RowDefinition Height="Auto"/>
|
||||||
<RowDefinition Height="Auto"/>
|
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
<Grid.ColumnDefinitions>
|
<Grid.ColumnDefinitions>
|
||||||
<ColumnDefinition Width="100"/>
|
<ColumnDefinition Width="100"/>
|
||||||
@@ -165,34 +164,20 @@
|
|||||||
Content="支持 SSE 流式输出"
|
Content="支持 SSE 流式输出"
|
||||||
Margin="0,5"/>
|
Margin="0,5"/>
|
||||||
|
|
||||||
<Label Grid.Row="4" Grid.Column="0" Content="推理保护"/>
|
<Label Grid.Row="4" Grid.Column="0" Content="高级参数"/>
|
||||||
<StackPanel Grid.Row="4" Grid.Column="1" Orientation="Horizontal" Margin="0,5">
|
|
||||||
<CheckBox x:Name="_reasoningGuardCheck"
|
|
||||||
Content="启用"
|
|
||||||
VerticalAlignment="Center"/>
|
|
||||||
<Label Content="推理 token 阈值"
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
Margin="10,0,0,0"/>
|
|
||||||
<TextBox x:Name="_reasoningTokenLimitBox"
|
|
||||||
Width="80"
|
|
||||||
VerticalAlignment="Center"
|
|
||||||
ToolTip="留空使用 max(4096, max_tokens / 2);仅流式模型生效"/>
|
|
||||||
</StackPanel>
|
|
||||||
|
|
||||||
<Label Grid.Row="5" Grid.Column="0" Content="高级参数"/>
|
|
||||||
<TextBox x:Name="_extraParamsBox"
|
<TextBox x:Name="_extraParamsBox"
|
||||||
Grid.Row="5" Grid.Column="1"
|
Grid.Row="4" Grid.Column="1"
|
||||||
MinHeight="80"
|
MinHeight="80"
|
||||||
AcceptsReturn="True"
|
AcceptsReturn="True"
|
||||||
ScrollViewer.VerticalScrollBarVisibility="Auto"/>
|
ScrollViewer.VerticalScrollBarVisibility="Auto"/>
|
||||||
|
|
||||||
<StackPanel Grid.Row="6" Grid.Column="1" Orientation="Horizontal" HorizontalAlignment="Right">
|
<StackPanel Grid.Row="5" Grid.Column="1" Orientation="Horizontal" HorizontalAlignment="Right">
|
||||||
<Button Content="应用模型修改" Click="OnApplyModelClick" Margin="2"/>
|
<Button Content="应用模型修改" Click="OnApplyModelClick" Margin="2"/>
|
||||||
<Button Content="从已知模板填充" Click="OnFillFromKnownModelsClick" Margin="2"/>
|
<Button Content="从已知模板填充" Click="OnFillFromKnownModelsClick" Margin="2"/>
|
||||||
</StackPanel>
|
</StackPanel>
|
||||||
|
|
||||||
<TextBlock x:Name="_modelStatusText"
|
<TextBlock x:Name="_modelStatusText"
|
||||||
Grid.Row="7" Grid.ColumnSpan="2"
|
Grid.Row="6" Grid.ColumnSpan="2"
|
||||||
Foreground="Gray" Margin="0,5"/>
|
Foreground="Gray" Margin="0,5"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|||||||
@@ -311,11 +311,6 @@ namespace AnotherReplayReader
|
|||||||
_contextLengthBox.Text = _currentModel.ContextLength.ToString();
|
_contextLengthBox.Text = _currentModel.ContextLength.ToString();
|
||||||
_contextBudgetBox.Text = _currentModel.ContextBudget is { } budget ? budget.ToString() : "0";
|
_contextBudgetBox.Text = _currentModel.ContextBudget is { } budget ? budget.ToString() : "0";
|
||||||
_supportsSseCheck.IsChecked = _currentModel.IsStream;
|
_supportsSseCheck.IsChecked = _currentModel.IsStream;
|
||||||
_reasoningGuardCheck.IsChecked = _currentModel.ReasoningGuardEnabled;
|
|
||||||
_reasoningTokenLimitBox.Text =
|
|
||||||
_currentModel.ReasoningGuardTokenLimit is { } guardLimit
|
|
||||||
? guardLimit.ToString()
|
|
||||||
: "";
|
|
||||||
|
|
||||||
// 显示 ExtraParameters 为缩进 JSON
|
// 显示 ExtraParameters 为缩进 JSON
|
||||||
var json = JsonSerializer.Serialize(
|
var json = JsonSerializer.Serialize(
|
||||||
@@ -370,8 +365,6 @@ namespace AnotherReplayReader
|
|||||||
ContextLength = known.ContextLength,
|
ContextLength = known.ContextLength,
|
||||||
ContextBudget = known.ContextBudget,
|
ContextBudget = known.ContextBudget,
|
||||||
IsStream = known.IsStream,
|
IsStream = known.IsStream,
|
||||||
ReasoningGuardEnabled = known.ReasoningGuardEnabled,
|
|
||||||
ReasoningGuardTokenLimit = known.ReasoningGuardTokenLimit,
|
|
||||||
ExtraParameters = new Dictionary<string, object>(known.ExtraParameters)
|
ExtraParameters = new Dictionary<string, object>(known.ExtraParameters)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -474,16 +467,6 @@ namespace AnotherReplayReader
|
|||||||
_currentModel.ContextBudget = null;
|
_currentModel.ContextBudget = null;
|
||||||
}
|
}
|
||||||
_currentModel.IsStream = _supportsSseCheck.IsChecked == true;
|
_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
|
try
|
||||||
{
|
{
|
||||||
@@ -529,8 +512,6 @@ namespace AnotherReplayReader
|
|||||||
_currentModel.IsStream = known.IsStream;
|
_currentModel.IsStream = known.IsStream;
|
||||||
_currentModel.ContextLength = known.ContextLength;
|
_currentModel.ContextLength = known.ContextLength;
|
||||||
_currentModel.ContextBudget = known.ContextBudget;
|
_currentModel.ContextBudget = known.ContextBudget;
|
||||||
_currentModel.ReasoningGuardEnabled = known.ReasoningGuardEnabled;
|
|
||||||
_currentModel.ReasoningGuardTokenLimit = known.ReasoningGuardTokenLimit;
|
|
||||||
RefreshModelList();
|
RefreshModelList();
|
||||||
_modelComboBox.SelectedItem = _modelComboBox.Items
|
_modelComboBox.SelectedItem = _modelComboBox.Items
|
||||||
.OfType<ModelDisplayItem>()
|
.OfType<ModelDisplayItem>()
|
||||||
@@ -550,8 +531,6 @@ namespace AnotherReplayReader
|
|||||||
_contextLengthBox.Text = "";
|
_contextLengthBox.Text = "";
|
||||||
_contextBudgetBox.Text = "";
|
_contextBudgetBox.Text = "";
|
||||||
_supportsSseCheck.IsChecked = false;
|
_supportsSseCheck.IsChecked = false;
|
||||||
_reasoningGuardCheck.IsChecked = false;
|
|
||||||
_reasoningTokenLimitBox.Text = "";
|
|
||||||
_extraParamsBox.Text = "";
|
_extraParamsBox.Text = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
日期:2026-08-23
|
日期:2026-08-23
|
||||||
范围:OpenCodeGo / `deepseek-v4-flash`,OpenAI 兼容 `/chat/completions`
|
范围:OpenCodeGo / `deepseek-v4-flash`,OpenAI 兼容 `/chat/completions`
|
||||||
状态:实验性研究,未集成到主项目
|
状态:实验性研究,未集成到主项目;主项目曾短暂集成推理保护,后因实测影响输出质量而移除(详见 `PLAN_ai_analysis_v2.md`)
|
||||||
|
|
||||||
## 摘要
|
## 摘要
|
||||||
|
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
|
|
||||||
结论是:**仅发送 `reasoning_content` 或“上一轮思维链”并不稳定;成功率最高的方案是伪造一段 tool call 历史,并把续写要求、输出格式和需要引用的内容放入 tool 结果中。**
|
结论是:**仅发送 `reasoning_content` 或“上一轮思维链”并不稳定;成功率最高的方案是伪造一段 tool call 历史,并把续写要求、输出格式和需要引用的内容放入 tool 结果中。**
|
||||||
|
|
||||||
与本研究相关的代码在 `AiV2.Tests/Program.cs` 中,默认不会执行。
|
与本研究相关的实验代码已从 `AiV2.Tests/Program.cs` 中移除,不再保留可执行实验入口。
|
||||||
|
|
||||||
## 目标
|
## 目标
|
||||||
|
|
||||||
@@ -232,9 +232,11 @@ system
|
|||||||
- 不要假设 `reasoning_content` 的末尾内容一定能被模型召回。
|
- 不要假设 `reasoning_content` 的末尾内容一定能被模型召回。
|
||||||
- 增加原始响应/请求日志,以便区分“模型未看到”和“模型看到了但没引用”。
|
- 增加原始响应/请求日志,以便区分“模型未看到”和“模型看到了但没引用”。
|
||||||
|
|
||||||
## 主项目集成状态(2026-08-23)
|
## 主项目集成状态(2026-08-23,历史记录;该功能已全部移除)
|
||||||
|
|
||||||
- 主项目已落地模型级“推理保护”实验开关,默认关闭,仅在 `IsStream=true` 的模型上生效。
|
> 以下内容是当时的集成状态,不代表当前代码。当前主项目已移除推理保护及所有相关代码/测试。
|
||||||
|
|
||||||
|
- 主项目曾落地模型级“推理保护”实验开关,默认关闭,仅在 `IsStream=true` 的模型上生效。
|
||||||
- 累计推理 token 达到阈值后停止读取当前 SSE 响应,保留部分推理和正文;最多发起一次续写。
|
- 累计推理 token 达到阈值后停止读取当前 SSE 响应,保留部分推理和正文;最多发起一次续写。
|
||||||
- 续写采用本报告推荐的伪造 tool call 历史:`assistant(reasoning_content + tool_calls)` → `tool(tool_call_id + 收尾指令)`。
|
- 续写采用本报告推荐的伪造 tool call 历史:`assistant(reasoning_content + tool_calls)` → `tool(tool_call_id + 收尾指令)`。
|
||||||
- 首请求不携带 `tools`,只有续写请求注入 `tools` 与 `tool_choice=none`;缓存命中为 best-effort。
|
- 首请求不携带 `tools`,只有续写请求注入 `tools` 与 `tool_choice=none`;缓存命中为 best-effort。
|
||||||
@@ -248,9 +250,7 @@ system
|
|||||||
|
|
||||||
## 专用测试状态
|
## 专用测试状态
|
||||||
|
|
||||||
- 测试代码:`AiV2.Tests/Program.cs` 中的 `OpenCodeGoFakeToolCallTests`。
|
- 原测试代码:`AiV2.Tests/Program.cs` 中的 `OpenCodeGoFakeToolCallTests`。
|
||||||
- 默认行为:不执行。
|
- 现状:已随推理保护一起移除,不再保留可执行实验入口。
|
||||||
- 启用条件:`ARR_AI_E2E=1` 且 `ARR_AI_E2E_TOOL=1`。
|
|
||||||
- 输出:Markdown 报告,路径通过 `ARR_AI_TOOL_REPORT_PATH` 指定。
|
|
||||||
|
|
||||||
主项目中的实验性 `ChatMessage` 扩展、`AiReasoningContinuationSettings`、UI 开关、回传/降级逻辑和 tool call 历史构造均已移除。
|
主项目中的实验性 `ChatMessage` 扩展、`AiReasoningContinuationSettings`、UI 开关、回传/降级逻辑和 tool call 历史构造均已移除。
|
||||||
|
|||||||
+1
-803
@@ -46,9 +46,6 @@ namespace AiV2.Tests
|
|||||||
Run("UserKnowledgeOverlay", UserKnowledgeOverlayTests.Run);
|
Run("UserKnowledgeOverlay", UserKnowledgeOverlayTests.Run);
|
||||||
Run("PromptBuilders", PromptBuildersTests.Run);
|
Run("PromptBuilders", PromptBuildersTests.Run);
|
||||||
Run("RevisionFactsAndSerialization", RevisionFactsAndSerializationTests.Run);
|
Run("RevisionFactsAndSerialization", RevisionFactsAndSerializationTests.Run);
|
||||||
Run("ReasoningGuard", ReasoningGuardTests.Run);
|
|
||||||
Run("OpenCodeGoFakeToolCallE2e", OpenCodeGoFakeToolCallTests.Run);
|
|
||||||
Run("OpenCodeGoGuardE2e", OpenCodeGoGuardE2e.Run);
|
|
||||||
Run("UserReplayFactIndexRepro", UserReplayFactIndexReproTests.Run);
|
Run("UserReplayFactIndexRepro", UserReplayFactIndexReproTests.Run);
|
||||||
|
|
||||||
Console.WriteLine();
|
Console.WriteLine();
|
||||||
@@ -992,46 +989,12 @@ namespace AiV2.Tests
|
|||||||
[property: JsonPropertyName("content"),
|
[property: JsonPropertyName("content"),
|
||||||
JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Content = null,
|
JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? Content = null,
|
||||||
[property: JsonPropertyName("reasoning_content"),
|
[property: JsonPropertyName("reasoning_content"),
|
||||||
JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? ReasoningContent = null,
|
JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? ReasoningContent = null)
|
||||||
[property: JsonPropertyName("tool_calls"),
|
|
||||||
JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] IReadOnlyList<ChatMessage.ToolCall>? ToolCalls = null,
|
|
||||||
[property: JsonPropertyName("tool_call_id"),
|
|
||||||
JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] string? ToolCallId = 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) =>
|
internal static ChatMessage Assistant(string? content, string? reasoningContent = null) =>
|
||||||
new("assistant",
|
new("assistant",
|
||||||
string.IsNullOrEmpty(content) ? null : content,
|
string.IsNullOrEmpty(content) ? null : content,
|
||||||
string.IsNullOrEmpty(reasoningContent) ? null : reasoningContent);
|
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<Result> CompleteAsync(
|
public async Task<Result> 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<HttpResponseMessage> 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<string, object>()
|
|
||||||
};
|
|
||||||
|
|
||||||
private static ImmutableList<MainAIAnalyze.ChatMessage> CreateMessages() =>
|
|
||||||
ImmutableList<MainAIAnalyze.ChatMessage>.Empty
|
|
||||||
.Add(new MainAIAnalyze.ChatMessage("system", "测试系统"))
|
|
||||||
.Add(new MainAIAnalyze.ChatMessage("user", "请分析。"));
|
|
||||||
|
|
||||||
private static async Task<MainAIAnalyze.Result> 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<AiSettings>(
|
|
||||||
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<AIAnalyze.ChatMessage>.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<AIAnalyze.ChatMessage>.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<string, object>(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<AIAnalyze.Result?> CompleteAndRecordAsync(
|
|
||||||
AIAnalyze analyzer,
|
|
||||||
AiRequestContext context,
|
|
||||||
ImmutableList<AIAnalyze.ChatMessage> 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<AIAnalyze.ChatMessage> 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<AiSettings>(
|
|
||||||
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<string, object>(model.ExtraParameters)
|
|
||||||
};
|
|
||||||
var messages = ImmutableList<MainAIAnalyze.ChatMessage>.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
|
internal static class UserReplayFactIndexReproTests
|
||||||
{
|
{
|
||||||
public static void Run()
|
public static void Run()
|
||||||
|
|||||||
@@ -6,6 +6,13 @@
|
|||||||
- 版本:v2.2。WIP/CONTEXT/ADR 旧文档已删除;思维链回传实验单独记录在 `AI_REASONING_CONTINUATION_RESEARCH.md`。
|
- 版本:v2.2。WIP/CONTEXT/ADR 旧文档已删除;思维链回传实验单独记录在 `AI_REASONING_CONTINUATION_RESEARCH.md`。
|
||||||
- 关联文档:[AI_REASONING_CONTINUATION_RESEARCH.md](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 段内焦点窗口)
|
## 实施状态修订(2026-08-24 段内焦点窗口)
|
||||||
|
|
||||||
- 新增「段内焦点窗口」设计:**数据层尽量宽**(整段机械切片按上下文上限提供),**注意力层聚焦窄时间窗**(每个切片再切分为若干分析窗口,每轮一个窗口作为重点)。
|
- 新增「段内焦点窗口」设计:**数据层尽量宽**(整段机械切片按上下文上限提供),**注意力层聚焦窄时间窗**(每个切片再切分为若干分析窗口,每轮一个窗口作为重点)。
|
||||||
@@ -62,7 +69,7 @@
|
|||||||
- `AiV2.Tests` 仅保留 `OpenCodeGoFakeToolCallE2e` 专用测试:默认跳过,需同时设置 `ARR_AI_E2E=1` 与 `ARR_AI_E2E_TOOL=1`。
|
- `AiV2.Tests` 仅保留 `OpenCodeGoFakeToolCallE2e` 专用测试:默认跳过,需同时设置 `ARR_AI_E2E=1` 与 `ARR_AI_E2E_TOOL=1`。
|
||||||
- 实验结果、推荐方案与数据表格见 `AI_REASONING_CONTINUATION_RESEARCH.md`。
|
- 实验结果、推荐方案与数据表格见 `AI_REASONING_CONTINUATION_RESEARCH.md`。
|
||||||
|
|
||||||
### 2026-08-23 推理保护实现
|
### 2026-08-23 推理保护实现(历史记录,后续已移除)
|
||||||
|
|
||||||
- 主项目已实现默认关闭、模型级配置的推理保护:累计 `reasoning_content` 达到阈值后中断流式响应,并通过研究验证的 tool call 载体请求一次续写。
|
- 主项目已实现默认关闭、模型级配置的推理保护:累计 `reasoning_content` 达到阈值后中断流式响应,并通过研究验证的 tool call 载体请求一次续写。
|
||||||
- 续写只在保留推理末尾依次追加 `[INTERNAL_REASONING_TRUNCATED]` 与中文收尾句,不插入中间 checkpoint;首请求不携带 `tools`,续写请求才注入工具定义和 `tool_choice=none`。
|
- 续写只在保留推理末尾依次追加 `[INTERNAL_REASONING_TRUNCATED]` 与中文收尾句,不插入中间 checkpoint;首请求不携带 `tools`,续写请求才注入工具定义和 `tool_choice=none`。
|
||||||
|
|||||||
+6
-283
@@ -7,7 +7,6 @@ using System.Linq;
|
|||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Encodings.Web;
|
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.RegularExpressions;
|
using System.Text.RegularExpressions;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
@@ -26,48 +25,13 @@ namespace AnotherReplayReader.Utils
|
|||||||
Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] string? Content = null,
|
Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] string? Content = null,
|
||||||
[property: System.Text.Json.Serialization.JsonPropertyName("reasoning_content"),
|
[property: System.Text.Json.Serialization.JsonPropertyName("reasoning_content"),
|
||||||
System.Text.Json.Serialization.JsonIgnore(
|
System.Text.Json.Serialization.JsonIgnore(
|
||||||
Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] string? ReasoningContent = null,
|
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<ChatMessage.ToolCall>? 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) =>
|
public static ChatMessage Assistant(string? content, string? reasoningContent = null) =>
|
||||||
new(
|
new(
|
||||||
"assistant",
|
"assistant",
|
||||||
string.IsNullOrEmpty(content) ? null : content,
|
string.IsNullOrEmpty(content) ? null : content,
|
||||||
string.IsNullOrEmpty(reasoningContent) ? null : reasoningContent);
|
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(
|
public static string GetSystemPrompt(
|
||||||
@@ -1277,8 +1241,6 @@ PlayerA: 开始出兵
|
|||||||
public enum AIChunkType
|
public enum AIChunkType
|
||||||
{
|
{
|
||||||
Reasoning,
|
Reasoning,
|
||||||
ReasoningGuard,
|
|
||||||
ReasoningGuardRequest,
|
|
||||||
Content,
|
Content,
|
||||||
Error,
|
Error,
|
||||||
Json
|
Json
|
||||||
@@ -1294,13 +1256,6 @@ PlayerA: 开始出兵
|
|||||||
{
|
{
|
||||||
public string Response;
|
public string Response;
|
||||||
public string Reasoning;
|
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? PromptTokens;
|
||||||
public int? CompletionTokens;
|
public int? CompletionTokens;
|
||||||
public int? TotalTokens;
|
public int? TotalTokens;
|
||||||
@@ -1329,226 +1284,19 @@ PlayerA: 开始出兵
|
|||||||
Action<AIChunk>? onChunk,
|
Action<AIChunk>? onChunk,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var model = requestContext.Model;
|
return await Task.Run(
|
||||||
var guardEnabled = model.ReasoningGuardEnabled && model.IsStream;
|
() => DoRequest(_http, messages, requestContext, onChunk, cancellationToken),
|
||||||
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);
|
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<ChatMessage> BuildReasoningContinuationMessages(
|
|
||||||
ImmutableList<ChatMessage> 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<string, object>(
|
|
||||||
context.Model.ExtraParameters)
|
|
||||||
};
|
|
||||||
model.ExtraParameters["tools"] =
|
|
||||||
new[] { AiReasoningGuard.BuildToolDefinition() };
|
|
||||||
model.ExtraParameters["tool_choice"] = "none";
|
|
||||||
return new AiRequestContext(context.Provider, model);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string SerializeRequestParams(
|
private static string SerializeRequestParams(
|
||||||
ImmutableList<ChatMessage> messages,
|
ImmutableList<ChatMessage> messages,
|
||||||
AiRequestContext context,
|
AiRequestContext context)
|
||||||
bool writeIndented = false)
|
|
||||||
{
|
{
|
||||||
var requestParams = ProcessRequestParams(
|
var requestParams = ProcessRequestParams(
|
||||||
messages,
|
messages,
|
||||||
context.BuildRequestParams());
|
context.BuildRequestParams());
|
||||||
return JsonSerializer.Serialize(
|
return JsonSerializer.Serialize(requestParams);
|
||||||
requestParams,
|
|
||||||
writeIndented
|
|
||||||
? new JsonSerializerOptions
|
|
||||||
{
|
|
||||||
WriteIndented = true,
|
|
||||||
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
|
||||||
}
|
|
||||||
: null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<Result> DoRequest(
|
private static async Task<Result> DoRequest(
|
||||||
@@ -1556,8 +1304,7 @@ PlayerA: 开始出兵
|
|||||||
ImmutableList<ChatMessage> messages,
|
ImmutableList<ChatMessage> messages,
|
||||||
AiRequestContext requestContext,
|
AiRequestContext requestContext,
|
||||||
Action<AIChunk>? onChunk,
|
Action<AIChunk>? onChunk,
|
||||||
CancellationToken cancellationToken,
|
CancellationToken cancellationToken)
|
||||||
int? reasoningTokenLimit = null)
|
|
||||||
{
|
{
|
||||||
var provider = requestContext.Provider;
|
var provider = requestContext.Provider;
|
||||||
var isStream = requestContext.Model.IsStream;
|
var isStream = requestContext.Model.IsStream;
|
||||||
@@ -1579,8 +1326,6 @@ PlayerA: 开始出兵
|
|||||||
var fullBuilder = new StringBuilder();
|
var fullBuilder = new StringBuilder();
|
||||||
var reasoningBuilder = new StringBuilder();
|
var reasoningBuilder = new StringBuilder();
|
||||||
var result = new Result();
|
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
|
// 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,
|
reasoningBuilder,
|
||||||
result,
|
result,
|
||||||
onChunk);
|
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
|
else
|
||||||
@@ -1656,14 +1387,6 @@ PlayerA: 开始出兵
|
|||||||
|
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
result.Reasoning = reasoningBuilder.ToString();
|
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)
|
if (fullBuilder.Length == 0)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,181 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
|
|
||||||
namespace AnotherReplayReader.Utils
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// 推理保护策略:截断 reasoning_content,并通过研究验证过的 tool call 历史
|
|
||||||
/// 让模型从已有推理自然收尾。
|
|
||||||
/// </summary>
|
|
||||||
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" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 保留尽可能多的推理前缀,并在末尾追加截断标记与收尾决定。
|
|
||||||
/// </summary>
|
|
||||||
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<string>();
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -48,17 +48,6 @@ namespace AnotherReplayReader
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public int? ContextBudget { get; set; }
|
public int? ContextBudget { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 推理保护:累计 reasoning_content 达到阈值时中断流式响应并请求一次续写。
|
|
||||||
/// 默认关闭。
|
|
||||||
/// </summary>
|
|
||||||
public bool ReasoningGuardEnabled { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 推理保护的 token 阈值。null 或 0 使用派生值 max(4096, provider.DefaultMaxTokens / 2)。
|
|
||||||
/// </summary>
|
|
||||||
public int? ReasoningGuardTokenLimit { get; set; }
|
|
||||||
|
|
||||||
public Dictionary<string, object> ExtraParameters { get; set; } = [];
|
public Dictionary<string, object> ExtraParameters { get; set; } = [];
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user