尝试重新思考得更短
This commit is contained in:
+323
-1
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user