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