尝试重新思考得更短

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
+329 -19
View File
@@ -7,6 +7,7 @@ using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
@@ -20,7 +21,54 @@ namespace AnotherReplayReader.Utils
/// <summary>聊天消息。属性名保持小写以兼容 OpenAI 兼容端点。</summary>
public sealed record ChatMessage(
[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(
Replay replay,
@@ -1162,6 +1210,7 @@ PlayerA: 开始出兵
public enum AIChunkType
{
Reasoning,
ReasoningGuard,
Content,
Error,
Json
@@ -1176,6 +1225,14 @@ PlayerA: 开始出兵
public struct Result
{
public string Response;
public string Reasoning;
public string? ReasoningFormatState;
public bool ReasoningInterrupted;
public bool ContinuationApplied;
public string? ReasoningContinuationError;
public string? RequestJson;
public string? ReasoningOriginalRequestJson;
public string? ReasoningContinuationRequestJson;
public int? PromptTokens;
public int? CompletionTokens;
public int? TotalTokens;
@@ -1185,36 +1242,243 @@ PlayerA: 开始出兵
private readonly HttpClient _http;
public AIAnalyze()
{
_http = new HttpClient
: this(new HttpClient
{
Timeout = TimeSpan.FromMinutes(5),
};
})
{
}
internal AIAnalyze(HttpClient http)
{
_http = http ?? throw new ArgumentNullException(nameof(http));
}
/// <summary>对给定的消息列表发起一次完整的 chat completion 请求(流式),不修改内部状态。</summary>
public async Task<Result> CompleteAsync(
ImmutableList<ChatMessage> messages,
AiRequestContext requestContext,
Action<AIChunk> onChunk,
Action<AIChunk>? onChunk,
CancellationToken cancellationToken)
{
return await Task.Run(
() => DoRequest(_http, messages, requestContext, onChunk, cancellationToken),
var model = requestContext.Model;
var guardEnabled = model.ReasoningGuardEnabled && model.IsStream;
if (!guardEnabled)
{
return await Task.Run(
() => DoRequest(_http, messages, requestContext, onChunk, cancellationToken),
cancellationToken);
}
var tokenLimit = AiReasoningGuard.GetEffectiveTokenLimit(
requestContext.Provider, model);
var firstResult = await Task.Run(
() => DoRequest(
_http,
messages,
requestContext,
onChunk,
cancellationToken,
tokenLimit),
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(
HttpClient http,
ImmutableList<ChatMessage> messages,
AiRequestContext requestContext,
Action<AIChunk> onChunk,
CancellationToken cancellationToken)
Action<AIChunk>? onChunk,
CancellationToken cancellationToken,
int? reasoningTokenLimit = null)
{
var provider = requestContext.Provider;
var requestParams = ProcessRequestParams(messages, requestContext.BuildRequestParams());
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");
using var request = new HttpRequestMessage(HttpMethod.Post, uri);
@@ -1230,7 +1494,10 @@ PlayerA: 开始出兵
using var reader = new StreamReader(responseStream);
var fullBuilder = new StringBuilder();
var reasoningBuilder = new StringBuilder();
var result = new Result();
result.RequestJson = inputJson;
var interrupted = false;
// 根据模式分别读取响应
// if response.Content.Headers.ContentType is "text/event-stream", then it's stream mode, otherwise it's non-stream mode
@@ -1268,17 +1535,53 @@ PlayerA: 开始出兵
});
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
{
var json = await reader.ReadToEndAsync();
using var doc = JsonDocument.Parse(json);
ProcessJsonDocument(doc, isStream: false, fullBuilder, result, onChunk);
ProcessJsonDocument(
doc,
isStream: false,
fullBuilder,
reasoningBuilder,
result,
onChunk);
}
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)
{
throw new Exception("AI分析失败,返回内容为空");
@@ -1296,8 +1599,9 @@ PlayerA: 开始出兵
JsonDocument doc,
bool isStream,
StringBuilder fullBuilder,
StringBuilder reasoningBuilder,
Result result,
Action<AIChunk> onChunk)
Action<AIChunk>? onChunk)
{
// 提取 usage(如果存在)
if (doc.RootElement.TryGetProperty("usage", out var usage) && usage.ValueKind == JsonValueKind.Object)
@@ -1344,7 +1648,11 @@ PlayerA: 开始出兵
? choice.GetProperty("delta")
: choice.GetProperty("message");
ExtractContentFromObject(contentObj, fullBuilder, onChunk);
ExtractContentFromObject(
contentObj,
fullBuilder,
reasoningBuilder,
onChunk);
}
}
@@ -1354,7 +1662,8 @@ PlayerA: 开始出兵
private static void ExtractContentFromObject(
JsonElement contentObj,
StringBuilder fullBuilder,
Action<AIChunk> onChunk)
StringBuilder reasoningBuilder,
Action<AIChunk>? onChunk)
{
// 普通内容
if (contentObj.TryGetProperty("content", out var content))
@@ -1362,11 +1671,11 @@ PlayerA: 开始出兵
var text = content.GetString();
if (!string.IsNullOrEmpty(text))
{
fullBuilder.Append(text);
fullBuilder.Append(text!);
onChunk?.Invoke(new AIChunk
{
Type = AIChunkType.Content,
Text = text
Text = text!
});
}
}
@@ -1377,10 +1686,11 @@ PlayerA: 开始出兵
var text = reasoning.GetString();
if (!string.IsNullOrEmpty(text))
{
reasoningBuilder.Append(text!);
onChunk?.Invoke(new AIChunk
{
Type = AIChunkType.Reasoning,
Text = text
Text = text!
});
}
}
+181
View File
@@ -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;
}
}
}
+11
View File
@@ -48,6 +48,17 @@ namespace AnotherReplayReader
/// </summary>
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; } = [];
/// <summary>