尝试重新思考得更短

This commit is contained in:
2026-08-23 22:34:30 +02:00
parent 8e6d245c1c
commit f33a6829e8
9 changed files with 960 additions and 25 deletions
+323 -1
View File
@@ -15,6 +15,7 @@ using AnotherReplayReader;
using AnotherReplayReader.ReplayFile;
using AnotherReplayReader.Utils;
using AIAnalyze = AiV2.Tests.TestAIAnalyze;
using MainAIAnalyze = AnotherReplayReader.Utils.AIAnalyze;
namespace AiV2.Tests
{
@@ -43,7 +44,9 @@ namespace AiV2.Tests
Run("UserKnowledgeOverlay", UserKnowledgeOverlayTests.Run);
Run("PromptBuilders", PromptBuildersTests.Run);
Run("RevisionFactsAndSerialization", RevisionFactsAndSerializationTests.Run);
Run("ReasoningGuard", ReasoningGuardTests.Run);
Run("OpenCodeGoFakeToolCallE2e", OpenCodeGoFakeToolCallTests.Run);
Run("OpenCodeGoGuardE2e", OpenCodeGoGuardE2e.Run);
Run("UserReplayFactIndexRepro", UserReplayFactIndexReproTests.Run);
Console.WriteLine();
@@ -966,6 +969,234 @@ namespace AiV2.Tests
}
}
internal sealed class GuardHttpHandler : HttpMessageHandler
{
public int RequestCount;
public string? FirstRequestBody;
public string? SecondRequestBody;
public bool FirstResponseContentOnly;
public bool SecondResponseInterrupted;
protected override async Task<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 =
@@ -1382,7 +1613,7 @@ namespace AiV2.Tests
report.AppendLine("```");
}
private static string FindSettingsPath()
internal static string FindSettingsPath()
{
var explicitPath = Environment.GetEnvironmentVariable("ARR_AI_SETTINGS_PATH");
if (!string.IsNullOrWhiteSpace(explicitPath) && File.Exists(explicitPath))
@@ -1412,6 +1643,97 @@ namespace AiV2.Tests
}
}
internal static class OpenCodeGoGuardE2e
{
public static void Run()
{
if (Environment.GetEnvironmentVariable("ARR_AI_GUARD_E2E") != "1")
{
Console.WriteLine(
" [跳过] 未设置 ARR_AI_GUARD_E2E=1,跳过真实推理保护实验");
return;
}
RunAsync().GetAwaiter().GetResult();
}
private static async Task RunAsync()
{
var settingsPath = OpenCodeGoFakeToolCallTests.FindSettingsPath();
var settings = JsonSerializer.Deserialize<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
{
public static void Run()