尝试重新思考得更短
This commit is contained in:
+56
-1
@@ -364,6 +364,7 @@ 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;
|
||||||
AppendLog(
|
AppendLog(
|
||||||
@@ -490,6 +491,7 @@ namespace AnotherReplayReader
|
|||||||
OnChunk,
|
OnChunk,
|
||||||
_linkedCts.Token);
|
_linkedCts.Token);
|
||||||
UpdateTokenDisplay(segmentResult);
|
UpdateTokenDisplay(segmentResult);
|
||||||
|
ReportReasoningGuardResult(segmentResult);
|
||||||
segmentResponse = segmentResult.Response;
|
segmentResponse = segmentResult.Response;
|
||||||
|
|
||||||
if (backqueryCount >= maxBackqueriesPerSegment)
|
if (backqueryCount >= maxBackqueriesPerSegment)
|
||||||
@@ -582,6 +584,7 @@ namespace AnotherReplayReader
|
|||||||
_suppressDisplay = false;
|
_suppressDisplay = false;
|
||||||
}
|
}
|
||||||
UpdateTokenDisplay(revisionResult);
|
UpdateTokenDisplay(revisionResult);
|
||||||
|
ReportReasoningGuardResult(revisionResult);
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(revisionResult.Response))
|
if (string.IsNullOrWhiteSpace(revisionResult.Response))
|
||||||
{
|
{
|
||||||
@@ -675,6 +678,7 @@ namespace AnotherReplayReader
|
|||||||
_linkedCts.Token);
|
_linkedCts.Token);
|
||||||
|
|
||||||
UpdateTokenDisplay(result);
|
UpdateTokenDisplay(result);
|
||||||
|
ReportReasoningGuardResult(result);
|
||||||
|
|
||||||
// 总结轮回查:允许模型请求一次远处原始区间,作为同一会话的追加输入。
|
// 总结轮回查:允许模型请求一次远处原始区间,作为同一会话的追加输入。
|
||||||
if (_replayData is null)
|
if (_replayData is null)
|
||||||
@@ -727,6 +731,7 @@ namespace AnotherReplayReader
|
|||||||
OnChunk,
|
OnChunk,
|
||||||
_linkedCts.Token);
|
_linkedCts.Token);
|
||||||
UpdateTokenDisplay(finalSummary);
|
UpdateTokenDisplay(finalSummary);
|
||||||
|
ReportReasoningGuardResult(finalSummary);
|
||||||
AppendLog("总结完成", "已根据回查区间补充最终总结。", false);
|
AppendLog("总结完成", "已根据回查区间补充最终总结。", false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -752,7 +757,9 @@ namespace AnotherReplayReader
|
|||||||
var total = 0;
|
var total = 0;
|
||||||
foreach (var message in messages)
|
foreach (var message in messages)
|
||||||
{
|
{
|
||||||
total += AiContextBudget.EstimateTokens(message.Content);
|
total += AiContextBudget.EstimateTokens(message.Content ?? string.Empty);
|
||||||
|
total += AiContextBudget.EstimateTokens(
|
||||||
|
message.ReasoningContent ?? string.Empty);
|
||||||
}
|
}
|
||||||
var check = AiContextBudget.CheckRequestUsage(
|
var check = AiContextBudget.CheckRequestUsage(
|
||||||
total, requestContext.Provider, requestContext.Model);
|
total, requestContext.Provider, requestContext.Model);
|
||||||
@@ -806,6 +813,44 @@ namespace AnotherReplayReader
|
|||||||
_chunkQueue.Enqueue((chunk, DateTimeOffset.UtcNow));
|
_chunkQueue.Enqueue((chunk, DateTimeOffset.UtcNow));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void ReportReasoningGuardResult(AIAnalyze.Result result)
|
||||||
|
{
|
||||||
|
if (!result.ReasoningInterrupted
|
||||||
|
&& !result.ContinuationApplied
|
||||||
|
&& string.IsNullOrWhiteSpace(result.ReasoningContinuationError))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(result.ReasoningContinuationError))
|
||||||
|
{
|
||||||
|
AppendLog("推理保护警告", result.ReasoningContinuationError, false);
|
||||||
|
}
|
||||||
|
else if (result.ContinuationApplied)
|
||||||
|
{
|
||||||
|
AppendLog("推理保护", "已请求 AI 尽快收尾,并完成最终回答。", false);
|
||||||
|
}
|
||||||
|
else if (result.ReasoningInterrupted)
|
||||||
|
{
|
||||||
|
AppendLog("推理保护", "检测到推理内容超限,正在处理续写结果。", false);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(result.ReasoningOriginalRequestJson))
|
||||||
|
{
|
||||||
|
AppendLog(
|
||||||
|
"推理保护:首次请求消息",
|
||||||
|
result.ReasoningOriginalRequestJson,
|
||||||
|
true);
|
||||||
|
}
|
||||||
|
if (!string.IsNullOrWhiteSpace(result.ReasoningContinuationRequestJson))
|
||||||
|
{
|
||||||
|
AppendLog(
|
||||||
|
"推理保护:续写请求完整消息",
|
||||||
|
result.ReasoningContinuationRequestJson,
|
||||||
|
true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void StartThinkingBlock()
|
private void StartThinkingBlock()
|
||||||
{
|
{
|
||||||
_thinkingStartTime = DateTime.Now;
|
_thinkingStartTime = DateTime.Now;
|
||||||
@@ -1035,6 +1080,7 @@ 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;
|
||||||
@@ -1062,6 +1108,11 @@ namespace AnotherReplayReader
|
|||||||
{
|
{
|
||||||
errorSb.AppendLine(chunk.Text);
|
errorSb.AppendLine(chunk.Text);
|
||||||
}
|
}
|
||||||
|
else if (chunk.Type == AIAnalyze.AIChunkType.ReasoningGuard)
|
||||||
|
{
|
||||||
|
EndThinkingBlock();
|
||||||
|
guardTriggered = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (thinkSb.Length > 0)
|
if (thinkSb.Length > 0)
|
||||||
@@ -1080,6 +1131,10 @@ namespace AnotherReplayReader
|
|||||||
{
|
{
|
||||||
AddErrorBlock(errorSb.ToString());
|
AddErrorBlock(errorSb.ToString());
|
||||||
}
|
}
|
||||||
|
if (guardTriggered)
|
||||||
|
{
|
||||||
|
_currentContent = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void AutoScroll()
|
private void AutoScroll()
|
||||||
|
|||||||
@@ -143,6 +143,7 @@
|
|||||||
<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"/>
|
||||||
@@ -164,20 +165,34 @@
|
|||||||
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="4" Grid.Column="1"
|
Grid.Row="5" Grid.Column="1"
|
||||||
MinHeight="80"
|
MinHeight="80"
|
||||||
AcceptsReturn="True"
|
AcceptsReturn="True"
|
||||||
ScrollViewer.VerticalScrollBarVisibility="Auto"/>
|
ScrollViewer.VerticalScrollBarVisibility="Auto"/>
|
||||||
|
|
||||||
<StackPanel Grid.Row="5" Grid.Column="1" Orientation="Horizontal" HorizontalAlignment="Right">
|
<StackPanel Grid.Row="6" 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="6" Grid.ColumnSpan="2"
|
Grid.Row="7" Grid.ColumnSpan="2"
|
||||||
Foreground="Gray" Margin="0,5"/>
|
Foreground="Gray" Margin="0,5"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
</DockPanel>
|
</DockPanel>
|
||||||
|
|||||||
@@ -234,6 +234,11 @@ 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(
|
||||||
@@ -288,6 +293,8 @@ 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)
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -376,6 +383,16 @@ 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
|
||||||
{
|
{
|
||||||
@@ -415,6 +432,8 @@ 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>()
|
||||||
@@ -433,6 +452,8 @@ 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 = "";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -232,6 +232,18 @@ system
|
|||||||
- 不要假设 `reasoning_content` 的末尾内容一定能被模型召回。
|
- 不要假设 `reasoning_content` 的末尾内容一定能被模型召回。
|
||||||
- 增加原始响应/请求日志,以便区分“模型未看到”和“模型看到了但没引用”。
|
- 增加原始响应/请求日志,以便区分“模型未看到”和“模型看到了但没引用”。
|
||||||
|
|
||||||
|
## 主项目集成状态(2026-08-23)
|
||||||
|
|
||||||
|
- 主项目已落地模型级“推理保护”实验开关,默认关闭,仅在 `IsStream=true` 的模型上生效。
|
||||||
|
- 累计推理 token 达到阈值后停止读取当前 SSE 响应,保留部分推理和正文;最多发起一次续写。
|
||||||
|
- 续写采用本报告推荐的伪造 tool call 历史:`assistant(reasoning_content + tool_calls)` → `tool(tool_call_id + 收尾指令)`。
|
||||||
|
- 首请求不携带 `tools`,只有续写请求注入 `tools` 与 `tool_choice=none`;缓存命中为 best-effort。
|
||||||
|
- 保留推理在其末尾依次追加 `[INTERNAL_REASONING_TRUNCATED]` 与中文收尾句;收尾句不插入中间 checkpoint,也不提及“token limit/被截断”等技术细节。
|
||||||
|
- 续写工具结果包含“进入收尾阶段、立即停止展开、直接输出最终结果”和“最后一句已经宣告收尾,请立即执行”等强化措辞。
|
||||||
|
- UI 在触发推理保护后提供两个可折叠日志:首次请求消息与续写请求完整消息;用户可展开查看完整 `messages`、`tools`、`tool_choice` 和收尾指令。
|
||||||
|
- 默认测试新增 `ReasoningGuard` 套件后总计 173 项通过;真实 API A/B 仍需要用户手工运行 `ARR_AI_E2E=1` 验证。
|
||||||
|
- 可选真实环境测试为 `OpenCodeGoGuardE2e`:设置 `ARR_AI_GUARD_E2E=1` 启用,报告路径可用 `ARR_AI_GUARD_REPORT_PATH` 指定。
|
||||||
|
|
||||||
## 专用测试状态
|
## 专用测试状态
|
||||||
|
|
||||||
- 测试代码:`AiV2.Tests/Program.cs` 中的 `OpenCodeGoFakeToolCallTests`。
|
- 测试代码:`AiV2.Tests/Program.cs` 中的 `OpenCodeGoFakeToolCallTests`。
|
||||||
|
|||||||
+323
-1
@@ -15,6 +15,7 @@ using AnotherReplayReader;
|
|||||||
using AnotherReplayReader.ReplayFile;
|
using AnotherReplayReader.ReplayFile;
|
||||||
using AnotherReplayReader.Utils;
|
using AnotherReplayReader.Utils;
|
||||||
using AIAnalyze = AiV2.Tests.TestAIAnalyze;
|
using AIAnalyze = AiV2.Tests.TestAIAnalyze;
|
||||||
|
using MainAIAnalyze = AnotherReplayReader.Utils.AIAnalyze;
|
||||||
|
|
||||||
namespace AiV2.Tests
|
namespace AiV2.Tests
|
||||||
{
|
{
|
||||||
@@ -43,7 +44,9 @@ 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("OpenCodeGoFakeToolCallE2e", OpenCodeGoFakeToolCallTests.Run);
|
||||||
|
Run("OpenCodeGoGuardE2e", OpenCodeGoGuardE2e.Run);
|
||||||
Run("UserReplayFactIndexRepro", UserReplayFactIndexReproTests.Run);
|
Run("UserReplayFactIndexRepro", UserReplayFactIndexReproTests.Run);
|
||||||
|
|
||||||
Console.WriteLine();
|
Console.WriteLine();
|
||||||
@@ -966,6 +969,234 @@ namespace AiV2.Tests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal sealed class GuardHttpHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
public int RequestCount;
|
||||||
|
public string? FirstRequestBody;
|
||||||
|
public string? SecondRequestBody;
|
||||||
|
public bool FirstResponseContentOnly;
|
||||||
|
public bool SecondResponseInterrupted;
|
||||||
|
|
||||||
|
protected override async Task<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
|
internal static class OpenCodeGoFakeToolCallTests
|
||||||
{
|
{
|
||||||
private const string SystemPrompt =
|
private const string SystemPrompt =
|
||||||
@@ -1382,7 +1613,7 @@ namespace AiV2.Tests
|
|||||||
report.AppendLine("```");
|
report.AppendLine("```");
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string FindSettingsPath()
|
internal static string FindSettingsPath()
|
||||||
{
|
{
|
||||||
var explicitPath = Environment.GetEnvironmentVariable("ARR_AI_SETTINGS_PATH");
|
var explicitPath = Environment.GetEnvironmentVariable("ARR_AI_SETTINGS_PATH");
|
||||||
if (!string.IsNullOrWhiteSpace(explicitPath) && File.Exists(explicitPath))
|
if (!string.IsNullOrWhiteSpace(explicitPath) && File.Exists(explicitPath))
|
||||||
@@ -1412,6 +1643,97 @@ namespace AiV2.Tests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static class OpenCodeGoGuardE2e
|
||||||
|
{
|
||||||
|
public static void Run()
|
||||||
|
{
|
||||||
|
if (Environment.GetEnvironmentVariable("ARR_AI_GUARD_E2E") != "1")
|
||||||
|
{
|
||||||
|
Console.WriteLine(
|
||||||
|
" [跳过] 未设置 ARR_AI_GUARD_E2E=1,跳过真实推理保护实验");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
RunAsync().GetAwaiter().GetResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task RunAsync()
|
||||||
|
{
|
||||||
|
var settingsPath = OpenCodeGoFakeToolCallTests.FindSettingsPath();
|
||||||
|
var settings = JsonSerializer.Deserialize<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()
|
||||||
|
|||||||
@@ -48,6 +48,14 @@
|
|||||||
- `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 推理保护实现
|
||||||
|
|
||||||
|
- 主项目已实现默认关闭、模型级配置的推理保护:累计 `reasoning_content` 达到阈值后中断流式响应,并通过研究验证的 tool call 载体请求一次续写。
|
||||||
|
- 续写只在保留推理末尾依次追加 `[INTERNAL_REASONING_TRUNCATED]` 与中文收尾句,不插入中间 checkpoint;首请求不携带 `tools`,续写请求才注入工具定义和 `tool_choice=none`。
|
||||||
|
- `ChatMessage` 支持 `reasoning_content`/`tool_calls`/`tool_call_id`,`Result` 保留完整推理、中断/续写状态与预算/错误信息。
|
||||||
|
- UI 触发推理保护后会显示首次请求与续写请求的完整消息日志;日志默认折叠,用户可展开检查 `messages`/`tools`/`tool_choice`。
|
||||||
|
- `AiV2.Tests` 新增 `ReasoningGuard` 测试套件;默认测试总计 173 项通过。
|
||||||
|
|
||||||
## 1. 背景与目标
|
## 1. 背景与目标
|
||||||
|
|
||||||
应用现有 AI 分析流程为"全量日志 + LLM 分段建议 + 分段分析 + 总结",经旧文档与代码审视,存在四类问题:上下文膨胀(每轮重发全量日志)、验证层可信度(施法者/目标混淆、所有权证据缺失、解析脆弱)、知识层作用域(结构化数据无 mod 维度、提示词与验证知识漂移)、修订机制未落地。
|
应用现有 AI 分析流程为"全量日志 + LLM 分段建议 + 分段分析 + 总结",经旧文档与代码审视,存在四类问题:上下文膨胀(每轮重发全量日志)、验证层可信度(施法者/目标混淆、所有权证据缺失、解析脆弱)、知识层作用域(结构化数据无 mod 维度、提示词与验证知识漂移)、修订机制未落地。
|
||||||
|
|||||||
+327
-17
@@ -7,6 +7,7 @@ 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;
|
||||||
@@ -20,7 +21,54 @@ namespace AnotherReplayReader.Utils
|
|||||||
/// <summary>聊天消息。属性名保持小写以兼容 OpenAI 兼容端点。</summary>
|
/// <summary>聊天消息。属性名保持小写以兼容 OpenAI 兼容端点。</summary>
|
||||||
public sealed record ChatMessage(
|
public sealed record ChatMessage(
|
||||||
[property: System.Text.Json.Serialization.JsonPropertyName("role")] string Role,
|
[property: System.Text.Json.Serialization.JsonPropertyName("role")] string Role,
|
||||||
[property: System.Text.Json.Serialization.JsonPropertyName("content")] string Content);
|
[property: System.Text.Json.Serialization.JsonPropertyName("content"),
|
||||||
|
System.Text.Json.Serialization.JsonIgnore(
|
||||||
|
Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] string? Content = null,
|
||||||
|
[property: System.Text.Json.Serialization.JsonPropertyName("reasoning_content"),
|
||||||
|
System.Text.Json.Serialization.JsonIgnore(
|
||||||
|
Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] string? ReasoningContent = null,
|
||||||
|
[property: System.Text.Json.Serialization.JsonPropertyName("tool_calls"),
|
||||||
|
System.Text.Json.Serialization.JsonIgnore(
|
||||||
|
Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] IReadOnlyList<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) =>
|
||||||
|
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(
|
public static string GetSystemPrompt(
|
||||||
Replay replay,
|
Replay replay,
|
||||||
@@ -1162,6 +1210,7 @@ PlayerA: 开始出兵
|
|||||||
public enum AIChunkType
|
public enum AIChunkType
|
||||||
{
|
{
|
||||||
Reasoning,
|
Reasoning,
|
||||||
|
ReasoningGuard,
|
||||||
Content,
|
Content,
|
||||||
Error,
|
Error,
|
||||||
Json
|
Json
|
||||||
@@ -1176,6 +1225,14 @@ PlayerA: 开始出兵
|
|||||||
public struct Result
|
public struct Result
|
||||||
{
|
{
|
||||||
public string Response;
|
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? PromptTokens;
|
||||||
public int? CompletionTokens;
|
public int? CompletionTokens;
|
||||||
public int? TotalTokens;
|
public int? TotalTokens;
|
||||||
@@ -1185,36 +1242,243 @@ PlayerA: 开始出兵
|
|||||||
private readonly HttpClient _http;
|
private readonly HttpClient _http;
|
||||||
|
|
||||||
public AIAnalyze()
|
public AIAnalyze()
|
||||||
{
|
: this(new HttpClient
|
||||||
_http = new HttpClient
|
|
||||||
{
|
{
|
||||||
Timeout = TimeSpan.FromMinutes(5),
|
Timeout = TimeSpan.FromMinutes(5),
|
||||||
};
|
})
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
internal AIAnalyze(HttpClient http)
|
||||||
|
{
|
||||||
|
_http = http ?? throw new ArgumentNullException(nameof(http));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>对给定的消息列表发起一次完整的 chat completion 请求(流式),不修改内部状态。</summary>
|
/// <summary>对给定的消息列表发起一次完整的 chat completion 请求(流式),不修改内部状态。</summary>
|
||||||
public async Task<Result> CompleteAsync(
|
public async Task<Result> CompleteAsync(
|
||||||
ImmutableList<ChatMessage> messages,
|
ImmutableList<ChatMessage> messages,
|
||||||
AiRequestContext requestContext,
|
AiRequestContext requestContext,
|
||||||
Action<AIChunk> onChunk,
|
Action<AIChunk>? onChunk,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var model = requestContext.Model;
|
||||||
|
var guardEnabled = model.ReasoningGuardEnabled && model.IsStream;
|
||||||
|
if (!guardEnabled)
|
||||||
{
|
{
|
||||||
return await Task.Run(
|
return await Task.Run(
|
||||||
() => DoRequest(_http, messages, requestContext, onChunk, cancellationToken),
|
() => DoRequest(_http, messages, requestContext, onChunk, cancellationToken),
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var tokenLimit = AiReasoningGuard.GetEffectiveTokenLimit(
|
||||||
|
requestContext.Provider, model);
|
||||||
|
var firstResult = await Task.Run(
|
||||||
|
() => DoRequest(
|
||||||
|
_http,
|
||||||
|
messages,
|
||||||
|
requestContext,
|
||||||
|
onChunk,
|
||||||
|
cancellationToken,
|
||||||
|
tokenLimit),
|
||||||
|
cancellationToken);
|
||||||
|
if (!firstResult.ReasoningInterrupted)
|
||||||
|
{
|
||||||
|
return firstResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
var continuationMessages = BuildReasoningContinuationMessages(
|
||||||
|
messages,
|
||||||
|
firstResult.Reasoning,
|
||||||
|
tokenLimit,
|
||||||
|
out var formatState);
|
||||||
|
firstResult.ReasoningFormatState = formatState;
|
||||||
|
var continuationContext = BuildReasoningToolContext(requestContext);
|
||||||
|
var originalRequestJson = firstResult.RequestJson;
|
||||||
|
var continuationRequestJson = SerializeRequestParams(
|
||||||
|
continuationMessages,
|
||||||
|
continuationContext,
|
||||||
|
writeIndented: true);
|
||||||
|
var continuationPromptTokens = 0;
|
||||||
|
foreach (var message in continuationMessages)
|
||||||
|
{
|
||||||
|
continuationPromptTokens += AiContextBudget.EstimateTokens(
|
||||||
|
(message.Content ?? string.Empty)
|
||||||
|
+ "\n"
|
||||||
|
+ (message.ReasoningContent ?? string.Empty));
|
||||||
|
}
|
||||||
|
var continuationCheck = AiContextBudget.CheckRequestUsage(
|
||||||
|
continuationPromptTokens,
|
||||||
|
requestContext.Provider,
|
||||||
|
continuationContext.Model);
|
||||||
|
var continuationBudgetWarning = string.Empty;
|
||||||
|
if (continuationCheck.Block)
|
||||||
|
{
|
||||||
|
var message = "续写请求未通过预算检查:" + continuationCheck.Message;
|
||||||
|
if (!string.IsNullOrEmpty(firstResult.Response))
|
||||||
|
{
|
||||||
|
firstResult.ReasoningContinuationError = message;
|
||||||
|
firstResult.ContinuationApplied = false;
|
||||||
|
firstResult.ReasoningOriginalRequestJson = originalRequestJson;
|
||||||
|
firstResult.ReasoningContinuationRequestJson =
|
||||||
|
continuationRequestJson;
|
||||||
|
return firstResult;
|
||||||
|
}
|
||||||
|
throw new InvalidOperationException(message);
|
||||||
|
}
|
||||||
|
if (!continuationCheck.IsOk)
|
||||||
|
{
|
||||||
|
continuationBudgetWarning = continuationCheck.Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
Result secondResult = default;
|
||||||
|
var continuationError = string.Empty;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
secondResult = await Task.Run(
|
||||||
|
() => DoRequest(
|
||||||
|
_http,
|
||||||
|
continuationMessages,
|
||||||
|
continuationContext,
|
||||||
|
onChunk,
|
||||||
|
cancellationToken,
|
||||||
|
tokenLimit),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
catch (Exception ex) when (
|
||||||
|
!cancellationToken.IsCancellationRequested
|
||||||
|
&& ex is not OperationCanceledException)
|
||||||
|
{
|
||||||
|
continuationError = ex.Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(continuationError))
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(firstResult.Response))
|
||||||
|
{
|
||||||
|
firstResult.ReasoningContinuationError =
|
||||||
|
"续写请求失败,已保留首次部分正文:" + continuationError;
|
||||||
|
firstResult.ContinuationApplied = false;
|
||||||
|
firstResult.ReasoningOriginalRequestJson = originalRequestJson;
|
||||||
|
firstResult.ReasoningContinuationRequestJson =
|
||||||
|
continuationRequestJson;
|
||||||
|
return firstResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"推理续写失败,且没有可用的部分回答:" + continuationError);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (secondResult.ReasoningInterrupted)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(secondResult.Response))
|
||||||
|
{
|
||||||
|
secondResult.ReasoningContinuationError =
|
||||||
|
"续写仍超过推理上限,已使用续写部分正文。";
|
||||||
|
secondResult.ContinuationApplied = false;
|
||||||
|
secondResult.ReasoningOriginalRequestJson = originalRequestJson;
|
||||||
|
secondResult.ReasoningContinuationRequestJson =
|
||||||
|
continuationRequestJson;
|
||||||
|
return secondResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(firstResult.Response))
|
||||||
|
{
|
||||||
|
firstResult.ReasoningContinuationError =
|
||||||
|
"续写仍超过推理上限,已回退到首次部分正文。";
|
||||||
|
firstResult.ContinuationApplied = false;
|
||||||
|
firstResult.ReasoningOriginalRequestJson = originalRequestJson;
|
||||||
|
firstResult.ReasoningContinuationRequestJson =
|
||||||
|
continuationRequestJson;
|
||||||
|
return firstResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"推理续写仍超过上限,且没有可用的部分回答。");
|
||||||
|
}
|
||||||
|
|
||||||
|
secondResult.ContinuationApplied = true;
|
||||||
|
secondResult.ReasoningOriginalRequestJson = originalRequestJson;
|
||||||
|
secondResult.ReasoningContinuationRequestJson =
|
||||||
|
continuationRequestJson;
|
||||||
|
if (!string.IsNullOrEmpty(continuationBudgetWarning))
|
||||||
|
{
|
||||||
|
secondResult.ReasoningContinuationError = continuationBudgetWarning;
|
||||||
|
}
|
||||||
|
return secondResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ImmutableList<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(
|
||||||
|
ImmutableList<ChatMessage> messages,
|
||||||
|
AiRequestContext context,
|
||||||
|
bool writeIndented = false)
|
||||||
|
{
|
||||||
|
var requestParams = ProcessRequestParams(
|
||||||
|
messages,
|
||||||
|
context.BuildRequestParams());
|
||||||
|
return JsonSerializer.Serialize(
|
||||||
|
requestParams,
|
||||||
|
writeIndented
|
||||||
|
? new JsonSerializerOptions
|
||||||
|
{
|
||||||
|
WriteIndented = true,
|
||||||
|
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
|
||||||
|
}
|
||||||
|
: null);
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<Result> DoRequest(
|
private static async Task<Result> DoRequest(
|
||||||
HttpClient http,
|
HttpClient http,
|
||||||
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 requestParams = ProcessRequestParams(messages, requestContext.BuildRequestParams());
|
|
||||||
var isStream = requestContext.Model.IsStream;
|
var isStream = requestContext.Model.IsStream;
|
||||||
var inputJson = JsonSerializer.Serialize(requestParams);
|
var inputJson = SerializeRequestParams(messages, requestContext);
|
||||||
|
|
||||||
var uri = new Uri(new(provider.BaseUrl.TrimEnd('/') + "/"), "chat/completions");
|
var uri = new Uri(new(provider.BaseUrl.TrimEnd('/') + "/"), "chat/completions");
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Post, uri);
|
using var request = new HttpRequestMessage(HttpMethod.Post, uri);
|
||||||
@@ -1230,7 +1494,10 @@ PlayerA: 开始出兵
|
|||||||
using var reader = new StreamReader(responseStream);
|
using var reader = new StreamReader(responseStream);
|
||||||
|
|
||||||
var fullBuilder = new StringBuilder();
|
var fullBuilder = 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
|
||||||
@@ -1268,17 +1535,53 @@ PlayerA: 开始出兵
|
|||||||
});
|
});
|
||||||
|
|
||||||
using var doc = JsonDocument.Parse(data);
|
using var doc = JsonDocument.Parse(data);
|
||||||
ProcessJsonDocument(doc, isStream: true, fullBuilder, result, onChunk);
|
ProcessJsonDocument(
|
||||||
|
doc,
|
||||||
|
isStream: true,
|
||||||
|
fullBuilder,
|
||||||
|
reasoningBuilder,
|
||||||
|
result,
|
||||||
|
onChunk);
|
||||||
|
|
||||||
|
if (reasoningTokenLimit is { } guardLimit
|
||||||
|
&& guardLimit > 0
|
||||||
|
&& AiContextBudget.EstimateTokens(reasoningBuilder.ToString())
|
||||||
|
>= guardLimit)
|
||||||
|
{
|
||||||
|
interrupted = true;
|
||||||
|
onChunk?.Invoke(new AIChunk
|
||||||
|
{
|
||||||
|
Type = AIChunkType.ReasoningGuard,
|
||||||
|
Text = "检测到推理内容超过保护阈值,正在请求 AI 尽快收尾..."
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var json = await reader.ReadToEndAsync();
|
var json = await reader.ReadToEndAsync();
|
||||||
using var doc = JsonDocument.Parse(json);
|
using var doc = JsonDocument.Parse(json);
|
||||||
ProcessJsonDocument(doc, isStream: false, fullBuilder, result, onChunk);
|
ProcessJsonDocument(
|
||||||
|
doc,
|
||||||
|
isStream: false,
|
||||||
|
fullBuilder,
|
||||||
|
reasoningBuilder,
|
||||||
|
result,
|
||||||
|
onChunk);
|
||||||
}
|
}
|
||||||
|
|
||||||
response.EnsureSuccessStatusCode();
|
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)
|
if (fullBuilder.Length == 0)
|
||||||
{
|
{
|
||||||
throw new Exception("AI分析失败,返回内容为空");
|
throw new Exception("AI分析失败,返回内容为空");
|
||||||
@@ -1296,8 +1599,9 @@ PlayerA: 开始出兵
|
|||||||
JsonDocument doc,
|
JsonDocument doc,
|
||||||
bool isStream,
|
bool isStream,
|
||||||
StringBuilder fullBuilder,
|
StringBuilder fullBuilder,
|
||||||
|
StringBuilder reasoningBuilder,
|
||||||
Result result,
|
Result result,
|
||||||
Action<AIChunk> onChunk)
|
Action<AIChunk>? onChunk)
|
||||||
{
|
{
|
||||||
// 提取 usage(如果存在)
|
// 提取 usage(如果存在)
|
||||||
if (doc.RootElement.TryGetProperty("usage", out var usage) && usage.ValueKind == JsonValueKind.Object)
|
if (doc.RootElement.TryGetProperty("usage", out var usage) && usage.ValueKind == JsonValueKind.Object)
|
||||||
@@ -1344,7 +1648,11 @@ PlayerA: 开始出兵
|
|||||||
? choice.GetProperty("delta")
|
? choice.GetProperty("delta")
|
||||||
: choice.GetProperty("message");
|
: choice.GetProperty("message");
|
||||||
|
|
||||||
ExtractContentFromObject(contentObj, fullBuilder, onChunk);
|
ExtractContentFromObject(
|
||||||
|
contentObj,
|
||||||
|
fullBuilder,
|
||||||
|
reasoningBuilder,
|
||||||
|
onChunk);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1354,7 +1662,8 @@ PlayerA: 开始出兵
|
|||||||
private static void ExtractContentFromObject(
|
private static void ExtractContentFromObject(
|
||||||
JsonElement contentObj,
|
JsonElement contentObj,
|
||||||
StringBuilder fullBuilder,
|
StringBuilder fullBuilder,
|
||||||
Action<AIChunk> onChunk)
|
StringBuilder reasoningBuilder,
|
||||||
|
Action<AIChunk>? onChunk)
|
||||||
{
|
{
|
||||||
// 普通内容
|
// 普通内容
|
||||||
if (contentObj.TryGetProperty("content", out var content))
|
if (contentObj.TryGetProperty("content", out var content))
|
||||||
@@ -1362,11 +1671,11 @@ PlayerA: 开始出兵
|
|||||||
var text = content.GetString();
|
var text = content.GetString();
|
||||||
if (!string.IsNullOrEmpty(text))
|
if (!string.IsNullOrEmpty(text))
|
||||||
{
|
{
|
||||||
fullBuilder.Append(text);
|
fullBuilder.Append(text!);
|
||||||
onChunk?.Invoke(new AIChunk
|
onChunk?.Invoke(new AIChunk
|
||||||
{
|
{
|
||||||
Type = AIChunkType.Content,
|
Type = AIChunkType.Content,
|
||||||
Text = text
|
Text = text!
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1377,10 +1686,11 @@ PlayerA: 开始出兵
|
|||||||
var text = reasoning.GetString();
|
var text = reasoning.GetString();
|
||||||
if (!string.IsNullOrEmpty(text))
|
if (!string.IsNullOrEmpty(text))
|
||||||
{
|
{
|
||||||
|
reasoningBuilder.Append(text!);
|
||||||
onChunk?.Invoke(new AIChunk
|
onChunk?.Invoke(new AIChunk
|
||||||
{
|
{
|
||||||
Type = AIChunkType.Reasoning,
|
Type = AIChunkType.Reasoning,
|
||||||
Text = text
|
Text = text!
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
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,6 +48,17 @@ 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