Commit 12f2ed0d authored by 段廷瑞's avatar 段廷瑞

pref: Agent可观测性优化(命令:/stats /admin UI: 设置-统计看板)

parent 5b9144c4
No preview for this file type
...@@ -43,7 +43,7 @@ ...@@ -43,7 +43,7 @@
<member name="M:Tools.MyAgent.AgentRuntime.CreateEngine(Tools.MyAgent.Kb.KbService)"> <member name="M:Tools.MyAgent.AgentRuntime.CreateEngine(Tools.MyAgent.Kb.KbService)">
<summary>创建无 UI Agent 引擎。调用方负责配置、订阅事件和释放。</summary> <summary>创建无 UI Agent 引擎。调用方负责配置、订阅事件和释放。</summary>
</member> </member>
<member name="M:Tools.MyAgent.AgentRuntime.CreateAdminForm(System.Int32,System.Boolean)"> <member name="M:Tools.MyAgent.AgentRuntime.CreateAdminForm(System.Int32,System.Boolean,System.Boolean)">
<summary>创建 Admin 看板窗体(供宿主挂到菜单/工具栏)。</summary> <summary>创建 Admin 看板窗体(供宿主挂到菜单/工具栏)。</summary>
</member> </member>
<member name="T:Tools.MyAgent.AgentProductContext"> <member name="T:Tools.MyAgent.AgentProductContext">
...@@ -1223,6 +1223,9 @@ ...@@ -1223,6 +1223,9 @@
<para>与聊天窗 <c>/stats</c> <c>/diag</c> 互补:那两条是终端用户随口问,这里是管理者看趋势。</para> <para>与聊天窗 <c>/stats</c> <c>/diag</c> 互补:那两条是终端用户随口问,这里是管理者看趋势。</para>
</summary> </summary>
</member> </member>
<member name="M:Tools.MyAgent.Forms.AgentAdminForm.Summarize(System.Collections.Generic.List{Tools.MyAgent.TelemetryStore.RunRow},System.String)">
<summary>一组运行记录的 KPI 摘要行(概览双窗口共用)。</summary>
</member>
<member name="T:Tools.MyAgent.Forms.DashChart"> <member name="T:Tools.MyAgent.Forms.DashChart">
<summary> <summary>
看板自绘图表(柱状 / 横向条形)。零第三方依赖:GDI+ 直接画,约 120 行。 看板自绘图表(柱状 / 横向条形)。零第三方依赖:GDI+ 直接画,约 120 行。
...@@ -2267,8 +2270,23 @@ ...@@ -2267,8 +2270,23 @@
<para>落盘前统一脱敏:手机号 / 邮箱 / API Key / 密码 / 令牌。</para> <para>落盘前统一脱敏:手机号 / 邮箱 / API Key / 密码 / 令牌。</para>
</summary> </summary>
</member> </member>
<member name="F:Tools.MyAgent.AgentLogger.MaxLogBytes">
<summary>单个日志文件的轮转阈值(8MB,与观测 jsonl 一致 —— G6)。</summary>
</member>
<member name="M:Tools.MyAgent.AgentLogger.Log(System.String)"> <member name="M:Tools.MyAgent.AgentLogger.Log(System.String)">
<summary>追加一行日志(自动脱敏 + 时间戳)</summary> <summary>追加一行 INFO 级日志(自动脱敏 + 时间戳 + [INF] 前缀)</summary>
</member>
<member name="M:Tools.MyAgent.AgentLogger.LogWarning(System.String)">
<summary>追加一行 WARN 级日志(G6:日志分级)。</summary>
</member>
<member name="M:Tools.MyAgent.AgentLogger.LogError(System.String)">
<summary>追加一行 ERROR 级日志(G6:日志分级)。</summary>
</member>
<member name="M:Tools.MyAgent.AgentLogger.Write(System.String,System.String)">
<summary>实际写行:时间戳 + [级别] + 脱敏正文。</summary>
</member>
<member name="M:Tools.MyAgent.AgentLogger.NextLogFile">
<summary>当日日志文件(8MB 轮转:agent_yyyyMMdd.log → _1 → _2…)。</summary>
</member> </member>
<member name="M:Tools.MyAgent.AgentLogger.LogCall(System.String,System.Int32,System.Int32,System.Int64,System.String,System.String,System.String)"> <member name="M:Tools.MyAgent.AgentLogger.LogCall(System.String,System.Int32,System.Int32,System.Int64,System.String,System.String,System.String)">
<summary>模型调用记录(#29):model / token / 耗时 / 工具序列 / 结果</summary> <summary>模型调用记录(#29):model / token / 耗时 / 工具序列 / 结果</summary>
...@@ -2303,6 +2321,9 @@ ...@@ -2303,6 +2321,9 @@
<member name="F:Tools.MyAgent.AgentTelemetry.RunScope.QuestionHash"> <member name="F:Tools.MyAgent.AgentTelemetry.RunScope.QuestionHash">
<summary>提问哈希(SHA256 前 12 位;一轮算一次,非 hot path)。</summary> <summary>提问哈希(SHA256 前 12 位;一轮算一次,非 hot path)。</summary>
</member> </member>
<member name="F:Tools.MyAgent.AgentTelemetry.RunScope.QuestionPreview">
<summary>提问前 120 字(仅 detailed 级别填充;basic 恒空 —— §9 隐私分级)。</summary>
</member>
<member name="F:Tools.MyAgent.AgentTelemetry.RunScope.Model"> <member name="F:Tools.MyAgent.AgentTelemetry.RunScope.Model">
<summary>配置模型。</summary> <summary>配置模型。</summary>
</member> </member>
...@@ -2336,6 +2357,9 @@ ...@@ -2336,6 +2357,9 @@
<member name="F:Tools.MyAgent.AgentTelemetry.ToolSpan.ArgsSummary"> <member name="F:Tools.MyAgent.AgentTelemetry.ToolSpan.ArgsSummary">
<summary>参数摘要(经 <see cref="M:Tools.MyAgent.AgentLogger.Mask(System.String)"/> 后截断 80 字)。</summary> <summary>参数摘要(经 <see cref="M:Tools.MyAgent.AgentLogger.Mask(System.String)"/> 后截断 80 字)。</summary>
</member> </member>
<member name="F:Tools.MyAgent.AgentTelemetry.ToolSpan.ResultPreview">
<summary>工具返回前 200 字(仅 detailed 级别填充;basic 恒空 —— §9 隐私分级)。</summary>
</member>
<member name="F:Tools.MyAgent.AgentTelemetry.ToolSpan.StartOffsetMs"> <member name="F:Tools.MyAgent.AgentTelemetry.ToolSpan.StartOffsetMs">
<summary>相对本轮起点的偏移(ms)。</summary> <summary>相对本轮起点的偏移(ms)。</summary>
</member> </member>
...@@ -2357,6 +2381,12 @@ ...@@ -2357,6 +2381,12 @@
<member name="T:Tools.MyAgent.AgentTelemetry.RunStats"> <member name="T:Tools.MyAgent.AgentTelemetry.RunStats">
<summary>内存快照(<c>/stats</c> 与看板读这个)。</summary> <summary>内存快照(<c>/stats</c> 与看板读这个)。</summary>
</member> </member>
<member name="F:Tools.MyAgent.AgentTelemetry.RunStats.QueueDepth">
<summary>gauge:待落盘队列当前深度(观测自查)。</summary>
</member>
<member name="F:Tools.MyAgent.AgentTelemetry.RunStats.LogSizeToday">
<summary>gauge:Logs 目录当日观测文件总字节数(runs/metrics jsonl,观测自查)。</summary>
</member>
<member name="F:Tools.MyAgent.AgentTelemetry.RunStats.Counters"> <member name="F:Tools.MyAgent.AgentTelemetry.RunStats.Counters">
<summary>计数器全量副本(键形如 <c>agent_runs_total|outcome=success</c>)。</summary> <summary>计数器全量副本(键形如 <c>agent_runs_total|outcome=success</c>)。</summary>
</member> </member>
...@@ -2404,6 +2434,9 @@ ...@@ -2404,6 +2434,9 @@
<member name="M:Tools.MyAgent.AgentTelemetry.Get(System.String)"> <member name="M:Tools.MyAgent.AgentTelemetry.Get(System.String)">
<summary>取计数器值(不存在返回 0)。</summary> <summary>取计数器值(不存在返回 0)。</summary>
</member> </member>
<member name="M:Tools.MyAgent.AgentTelemetry.LogsSizeToday">
<summary>gauge:当日观测文件(runs/metrics jsonl)总字节数,供观测自查。</summary>
</member>
<member name="M:Tools.MyAgent.AgentTelemetry.SumPrefix(System.String)"> <member name="M:Tools.MyAgent.AgentTelemetry.SumPrefix(System.String)">
<summary>按前缀汇总(用于无维度的总量)。</summary> <summary>按前缀汇总(用于无维度的总量)。</summary>
</member> </member>
...@@ -2432,7 +2465,21 @@ ...@@ -2432,7 +2465,21 @@
<summary>一轮运行入队(满则丢弃并计数)。</summary> <summary>一轮运行入队(满则丢弃并计数)。</summary>
</member> </member>
<member name="M:Tools.MyAgent.AgentTelemetry.FlushMetrics"> <member name="M:Tools.MyAgent.AgentTelemetry.FlushMetrics">
<summary>落一条指标快照(计数器 + 直方图读数)。</summary> <summary>落一条指标快照(计数器 + 直方图读数;直方图带原始桶分布,供重启重放精确合并)。</summary>
</member>
<member name="M:Tools.MyAgent.AgentTelemetry.HistSnapshot(System.String)">
<summary>单个直方图的落盘形态:count/sum/min/max + 12 桶分布(P1-6 重放合并的数据源)。</summary>
</member>
<member name="F:Tools.MyAgent.AgentTelemetry._replayed">
<summary>
P1-6:启动时重放当日 metrics_*.jsonl 最后一条快照并合并进内存,
使 <c>/stats</c> 与设置页 KPI 在进程重启后不丢当日累计(看板读 jsonl 本就不受影响)。
<para>合并语义:计数器 = 存量 + 当前(重放只在启动时做一次,此刻当前≈0);
直方图按桶分布相加。旧格式快照(无 buckets)跳过直方图部分。</para>
</summary>
</member>
<member name="M:Tools.MyAgent.AgentTelemetry.MergeHist(Newtonsoft.Json.Linq.JToken,System.String)">
<summary>把一条落盘直方图快照按桶合并进内存(count/sum/min/max + buckets)。</summary>
</member> </member>
<member name="M:Tools.MyAgent.AgentTelemetry.Flush"> <member name="M:Tools.MyAgent.AgentTelemetry.Flush">
<summary>把队列与缓冲落盘(进程退出 / 手动调用)。</summary> <summary>把队列与缓冲落盘(进程退出 / 手动调用)。</summary>
...@@ -3301,6 +3348,9 @@ ...@@ -3301,6 +3348,9 @@
<member name="M:Tools.MyAgent.wMyAgent.SaveAgentConfig(Tools.MyAgent.Config.AgentConfig)"> <member name="M:Tools.MyAgent.wMyAgent.SaveAgentConfig(Tools.MyAgent.Config.AgentConfig)">
<summary>保存 Agent 配置文件到默认 Agent 目录下 config 文件夹(全部凭据经 DPAPI 加密落盘:共享密码 / ApiKey / EmbeddingApiKey / BackupApiKey / 预存连接串)</summary> <summary>保存 Agent 配置文件到默认 Agent 目录下 config 文件夹(全部凭据经 DPAPI 加密落盘:共享密码 / ApiKey / EmbeddingApiKey / BackupApiKey / 预存连接串)</summary>
</member> </member>
<member name="M:Tools.MyAgent.wMyAgent.RunStartupHealthCheck">
<summary>P1-4:HealthCheckOnStart=true 时启动后台被动自检并落 health.json(不做模型服务网络探测,避免拖慢启动)。</summary>
</member>
<member name="M:Tools.MyAgent.wMyAgent.WorkingDirForHistory"> <member name="M:Tools.MyAgent.wMyAgent.WorkingDirForHistory">
<summary>历史记录固定保存到默认 Agent 目录(与所选知识库无关)</summary> <summary>历史记录固定保存到默认 Agent 目录(与所选知识库无关)</summary>
</member> </member>
......
...@@ -343,6 +343,7 @@ namespace Tools.MyAgent.Agent ...@@ -343,6 +343,7 @@ namespace Tools.MyAgent.Agent
IsRunning = true; IsRunning = true;
_cts = CancellationTokenSource.CreateLinkedTokenSource(ct); _cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
OnStatusChanged?.Invoke(); OnStatusChanged?.Invoke();
int toolRounds = 0; // 提升到 try 外:catch 路径的 EndRun 也要带回 ToolRounds
try try
{ {
...@@ -355,11 +356,15 @@ namespace Tools.MyAgent.Agent ...@@ -355,11 +356,15 @@ namespace Tools.MyAgent.Agent
_answerKind = AnswerKind.Knowledge; _answerKind = AnswerKind.Knowledge;
_currentQuestion = LastUserQuestion(history); _currentQuestion = LastUserQuestion(history);
_runScope = AgentTelemetry.BeginRun(_currentQuestion.Length, AgentTelemetry.HashQuestion(_currentQuestion), _client.Model); _runScope = AgentTelemetry.BeginRun(_currentQuestion.Length, AgentTelemetry.HashQuestion(_currentQuestion), _client.Model);
if (_runScope != null) _runScope.RunId = _diagnosticSessionId; if (_runScope != null)
{
_runScope.RunId = _diagnosticSessionId;
if (AgentTelemetry.IsDetailed && !string.IsNullOrEmpty(_currentQuestion)) // §9:仅 detailed 存提问前 120 字
_runScope.QuestionPreview = _currentQuestion.Substring(0, Math.Min(120, _currentQuestion.Length));
}
var messages = await BuildMessagesAsync(history, _cts.Token).ConfigureAwait(false); var messages = await BuildMessagesAsync(history, _cts.Token).ConfigureAwait(false);
var tools = BuildTools(); var tools = BuildTools();
var stopwatch = Stopwatch.StartNew(); var stopwatch = Stopwatch.StartNew();
int toolRounds = 0;
var finalAnswer = new StringBuilder(); // 跨轮累计的回答正文(UI 事件为全量替换语义) var finalAnswer = new StringBuilder(); // 跨轮累计的回答正文(UI 事件为全量替换语义)
// P3 单轮直答(QuickAnswer):本地召回命中知识库时,注入证据做一次「无工具」补全即收尾, // P3 单轮直答(QuickAnswer):本地召回命中知识库时,注入证据做一次「无工具」补全即收尾,
...@@ -444,6 +449,7 @@ namespace Tools.MyAgent.Agent ...@@ -444,6 +449,7 @@ namespace Tools.MyAgent.Agent
if (!IsDisposed) if (!IsDisposed)
{ {
if (_runScope != null) { _runScope.ToolRounds = toolRounds; _runScope.AnswerKind = _answerKind.ToString(); }
AgentTelemetry.EndRun(_runScope, "success", "", null); AgentTelemetry.EndRun(_runScope, "success", "", null);
OnComplete?.Invoke(); OnComplete?.Invoke();
if (_toolTrace.Count > 0) if (_toolTrace.Count > 0)
...@@ -452,6 +458,7 @@ namespace Tools.MyAgent.Agent ...@@ -452,6 +458,7 @@ namespace Tools.MyAgent.Agent
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
if (_runScope != null) { _runScope.ToolRounds = toolRounds; _runScope.AnswerKind = _answerKind.ToString(); }
AgentTelemetry.EndRun(_runScope, "cancel", "", null); AgentTelemetry.EndRun(_runScope, "cancel", "", null);
if (!IsDisposed) if (!IsDisposed)
{ {
...@@ -465,6 +472,7 @@ namespace Tools.MyAgent.Agent ...@@ -465,6 +472,7 @@ namespace Tools.MyAgent.Agent
} }
catch (Exception ex) catch (Exception ex)
{ {
if (_runScope != null) { _runScope.ToolRounds = toolRounds; _runScope.AnswerKind = _answerKind.ToString(); }
AgentTelemetry.EndRun(_runScope, "error", ClassifyError(ex), ex); AgentTelemetry.EndRun(_runScope, "error", ClassifyError(ex), ex);
if (!IsDisposed) if (!IsDisposed)
{ {
...@@ -524,7 +532,10 @@ namespace Tools.MyAgent.Agent ...@@ -524,7 +532,10 @@ namespace Tools.MyAgent.Agent
// 部分命中时先明确告知「含模型补充内容」,避免用户把补充内容当成知识库原文 // 部分命中时先明确告知「含模型补充内容」,避免用户把补充内容当成知识库原文
if (!string.IsNullOrEmpty(recall.Reason)) OnTextDelta?.Invoke("\n\n" + recall.Reason); if (!string.IsNullOrEmpty(recall.Reason)) OnTextDelta?.Invoke("\n\n" + recall.Reason);
AgentLogger.Log($"本地召回命中:mode={recall.Mode} score={recall.Primary.Score:0.00} file={recall.Primary.RelPath} kw={string.Join("|", recall.Keywords)}"); // G7 隐私:关键词源自提问原文,basic 级别只记个数,detailed 才记关键词(与遥测隐私分级一致)
AgentLogger.Log(AgentTelemetry.IsDetailed
? $"本地召回命中:mode={recall.Mode} score={recall.Primary.Score:0.00} file={recall.Primary.RelPath} kw={string.Join("|", recall.Keywords)}"
: $"本地召回命中:mode={recall.Mode} score={recall.Primary.Score:0.00} file={recall.Primary.RelPath} kwCount={recall.Keywords.Count}");
var result = await SendAndLogAsync(messages, null, ct, "direct").ConfigureAwait(false); var result = await SendAndLogAsync(messages, null, ct, "direct").ConfigureAwait(false);
if (result.ReasoningContent.Length > 0) if (result.ReasoningContent.Length > 0)
...@@ -684,6 +695,8 @@ namespace Tools.MyAgent.Agent ...@@ -684,6 +695,8 @@ namespace Tools.MyAgent.Agent
&& _continueRounds < MaxContinuationRounds) && _continueRounds < MaxContinuationRounds)
{ {
_continueRounds++; _continueRounds++;
if (_runScope != null) _runScope.ContinuationCount = _continueRounds;
AgentTelemetry.Count("agent_continuation_total"); // §6 #7:续写触发(输出上限截断)
AppendContinuationMessages(messages, result); AppendContinuationMessages(messages, result);
OnTextDelta?.Invoke(finalAnswer.ToString()); OnTextDelta?.Invoke(finalAnswer.ToString());
AgentLogger.Log($"回答被输出上限(max_tokens)截断,自动续写第 {_continueRounds}/{MaxContinuationRounds} 轮"); AgentLogger.Log($"回答被输出上限(max_tokens)截断,自动续写第 {_continueRounds}/{MaxContinuationRounds} 轮");
...@@ -849,6 +862,8 @@ namespace Tools.MyAgent.Agent ...@@ -849,6 +862,8 @@ namespace Tools.MyAgent.Agent
int dropped = DropOldestUntilUnder(list, budget); int dropped = DropOldestUntilUnder(list, budget);
if (dropped > 0) if (dropped > 0)
{ {
AgentTelemetry.Count("agent_context_trim_total", dropped); // §6 #7:上下文裁剪(含条数)
if (_runScope != null) _runScope.ContextTrimCount += dropped;
_contextNotice = $"(对话历史较长,已自动省略最早 {dropped} 条消息以控制上下文)"; _contextNotice = $"(对话历史较长,已自动省略最早 {dropped} 条消息以控制上下文)";
OnTextDelta?.Invoke("\n\n" + _contextNotice); OnTextDelta?.Invoke("\n\n" + _contextNotice);
} }
...@@ -1272,6 +1287,8 @@ namespace Tools.MyAgent.Agent ...@@ -1272,6 +1287,8 @@ namespace Tools.MyAgent.Agent
DurationMs = sw.ElapsedMilliseconds, DurationMs = sw.ElapsedMilliseconds,
Outcome = "ok", Outcome = "ok",
ResultChars = (result ?? "").Length, ResultChars = (result ?? "").Length,
ResultPreview = AgentTelemetry.IsDetailed && !string.IsNullOrEmpty(result)
? result.Substring(0, Math.Min(200, result.Length)) : "", // §9:仅 detailed
Truncated = result != null && result.Length > MaxToolReturnChars, Truncated = result != null && result.Length > MaxToolReturnChars,
}); });
// #42 结果压缩:超长结果压缩后回填模型,完整结果落盘供溯源 // #42 结果压缩:超长结果压缩后回填模型,完整结果落盘供溯源
......
...@@ -425,6 +425,7 @@ namespace Tools.MyAgent.Agent ...@@ -425,6 +425,7 @@ namespace Tools.MyAgent.Agent
/// <summary>指数退避 + 抖动(约 1s / 2s,封顶 4s),可被用户取消</summary> /// <summary>指数退避 + 抖动(约 1s / 2s,封顶 4s),可被用户取消</summary>
private static async Task RetryDelayAsync(int attempt, CancellationToken ct) private static async Task RetryDelayAsync(int attempt, CancellationToken ct)
{ {
AgentTelemetry.Count("agent_http_retry_total"); // 网络抖动强度(超时/429/5xx/流中断统一计)
int delayMs; int delayMs;
lock (RetryJitter) lock (RetryJitter)
{ {
......
...@@ -101,9 +101,9 @@ namespace Tools.MyAgent ...@@ -101,9 +101,9 @@ namespace Tools.MyAgent
} }
/// <summary>创建 Admin 看板窗体(供宿主挂到菜单/工具栏)。</summary> /// <summary>创建 Admin 看板窗体(供宿主挂到菜单/工具栏)。</summary>
public Form CreateAdminForm(int days = 7, bool allowDetailed = false) public Form CreateAdminForm(int days = 7, bool allowDetailed = false, bool autoRefresh = false)
{ {
return new Tools.MyAgent.Forms.AgentAdminForm(null, days, allowDetailed); return new Tools.MyAgent.Forms.AgentAdminForm(null, days, allowDetailed, autoRefresh);
} }
private void EnsureDefaultResources() private void EnsureDefaultResources()
......
...@@ -34,7 +34,7 @@ namespace Tools.MyAgent.Forms ...@@ -34,7 +34,7 @@ namespace Tools.MyAgent.Forms
private readonly Timer _timer = new Timer(); private readonly Timer _timer = new Timer();
private readonly Label _hint = new Label(); private readonly Label _hint = new Label();
public AgentAdminForm(string logsRoot = null, int days = 7, bool allowDetailed = false) public AgentAdminForm(string logsRoot = null, int days = 7, bool allowDetailed = false, bool autoRefresh = false)
{ {
_allowDetailed = allowDetailed; _allowDetailed = allowDetailed;
_store = new TelemetryStore(logsRoot, days) { AllowDetailed = allowDetailed }; _store = new TelemetryStore(logsRoot, days) { AllowDetailed = allowDetailed };
...@@ -73,6 +73,8 @@ namespace Tools.MyAgent.Forms ...@@ -73,6 +73,8 @@ namespace Tools.MyAgent.Forms
_timer.Interval = 30000; _timer.Interval = 30000;
_timer.Tick += (s, e) => LoadData(); _timer.Tick += (s, e) => LoadData();
if (autoRefresh) chkAuto.Checked = true; // P1-5:AdminAutoRefresh 接线(CheckedChanged 已启用 Timer)
Controls.Add(_tabs); Controls.Add(_tabs);
Controls.Add(top); Controls.Add(top);
...@@ -206,38 +208,13 @@ namespace Tools.MyAgent.Forms ...@@ -206,38 +208,13 @@ namespace Tools.MyAgent.Forms
private void BindOverview() private void BindOverview()
{ {
int runs = _runs.Length; // P2-11:今日 vs 近 7 天 双窗口(原只有全期合计,看不出"今天用量多少")
int ok = 0, err = 0, cancel = 0; var today = DateTime.Now.Date;
long tokens = 0; double cost = 0; int tools = 0, llms = 0; var todays = new System.Collections.Generic.List<TelemetryStore.RunRow>();
foreach (var r in _runs) foreach (var r in _runs)
{ if (r.StartedAt >= today) todays.Add(r);
if (r.Outcome == "success") ok++;
else if (r.Outcome == "error") err++;
else cancel++;
tokens += r.PromptTokens + r.CompletionTokens;
cost += r.Cost;
tools += r.ToolCalls;
llms += r.LlmCalls;
}
long p50 = 0, p95 = 0;
var ts = new System.Collections.Generic.List<long>();
foreach (var r in _runs) ts.Add(r.TotalMs);
ts.Sort();
if (ts.Count > 0)
{
p50 = ts[(int)Math.Ceiling(ts.Count * 0.5) - 1];
p95 = ts[(int)Math.Ceiling(ts.Count * 0.95) - 1];
}
double rate = runs == 0 ? 0 : ok * 100.0 / runs;
double perRun = runs == 0 ? 0 : (double)llms / runs;
_kpi.Text = string.Format( _kpi.Text = Summarize(todays, "今日") + "\n" + Summarize(new System.Collections.Generic.List<TelemetryStore.RunRow>(_runs), "近 7 天");
"轮数 {0} 成功率 {1:F1}% 失败 {2} / 取消 {3}\n" +
"耗时 P50 {4}ms · P95 {5}ms 工具 {6} 次 · LLM {7} 次(均 {8:F1} 次/轮)\n" +
"Token {9} 费用 {10} 直答率 {11:F1}%",
runs, rate, err, cancel, p50, p95, tools, llms, perRun,
tokens, cost > 0 ? CostEstimator.Format(cost) : "—",
_store.AggregateModes(_runs).DirectRate);
long[] failed; long[] failed;
long[] okHours = _store.Hourly(_runs, out failed); long[] okHours = _store.Hourly(_runs, out failed);
...@@ -258,6 +235,41 @@ namespace Tools.MyAgent.Forms ...@@ -258,6 +235,41 @@ namespace Tools.MyAgent.Forms
_hourly.Invalidate(); _hourly.Invalidate();
} }
/// <summary>一组运行记录的 KPI 摘要行(概览双窗口共用)。</summary>
private string Summarize(System.Collections.Generic.List<TelemetryStore.RunRow> rows, string label)
{
try
{
int runs = rows.Count;
if (runs == 0) return label + ":无数据";
int ok = 0, err = 0, cancel = 0, tools = 0, llms = 0;
long tokens = 0, p95 = 0;
double cost = 0;
var ts = new System.Collections.Generic.List<long>();
foreach (var r in rows)
{
if (r.Outcome == "success") ok++;
else if (r.Outcome == "error") err++;
else cancel++;
tools += r.ToolCalls;
llms += r.LlmCalls;
tokens += r.PromptTokens + r.CompletionTokens;
cost += r.Cost;
ts.Add(r.TotalMs);
}
ts.Sort();
if (ts.Count > 0) p95 = ts[(int)Math.Ceiling(ts.Count * 0.95) - 1];
double direct = _store.AggregateModes(rows.ToArray()).DirectRate;
return string.Format(
"{0}:轮数 {1}(成功率 {2:F1}% · 失败 {3} · 取消 {4})· P95 {5}ms · 工具 {6} · LLM {7}(均 {8:F1}/轮)· 直答率 {9:F1}%\n" +
"  Token {10} · 费用 {11}",
label, runs, ok * 100.0 / runs, err, cancel, p95, tools, llms,
runs == 0 ? 0 : (double)llms / runs, direct, tokens,
cost > 0 ? CostEstimator.Format(cost) : "—");
}
catch { return label + ":—"; }
}
private void BindTimeline() private void BindTimeline()
{ {
_gridTime.Columns.Clear(); _gridTime.Columns.Clear();
...@@ -339,6 +351,22 @@ namespace Tools.MyAgent.Forms ...@@ -339,6 +351,22 @@ namespace Tools.MyAgent.Forms
_gridKb.Rows.Add("model 未召回", m.Model, "知识库未命中"); _gridKb.Rows.Add("model 未召回", m.Model, "知识库未命中");
_gridKb.Rows.Add("tool-loop 工具循环", m.ToolLoop, "多轮工具检索"); _gridKb.Rows.Add("tool-loop 工具循环", m.ToolLoop, "多轮工具检索");
_gridKb.Rows.Add("检索零命中", AgentTelemetry.Get("agent_kb_zero_hit_total"), "进程内累计(知识缺口信号)"); _gridKb.Rows.Add("检索零命中", AgentTelemetry.Get("agent_kb_zero_hit_total"), "进程内累计(知识缺口信号)");
// P2-12:TOP 未命中关键词仅 detailed 可见(查询词源自提问原文,受隐私闸门约束)
if (_allowDetailed)
{
try
{
var misses = Tools.MyAgent.Kb.KbService.RecentMisses(7, 10);
if (misses.Count > 0)
{
_gridKb.Rows.Add("── TOP 未命中关键词(近 7 天)──", "次数", "来源:search_misses.csv");
foreach (var miss in misses)
_gridKb.Rows.Add(miss.Query, miss.Count, "0 命中");
}
}
catch { }
}
} }
private void BindErrors() private void BindErrors()
......
...@@ -2208,6 +2208,7 @@ namespace Tools.MyAgent.Kb ...@@ -2208,6 +2208,7 @@ namespace Tools.MyAgent.Kb
{ {
try try
{ {
AgentTelemetry.Count("agent_kb_zero_hit_total"); // 知识缺口信号(看板④Tab 读取)
Directory.CreateDirectory(Path.GetDirectoryName(MissLogPath)); Directory.CreateDirectory(Path.GetDirectoryName(MissLogPath));
string line = string.Join(",", string line = string.Join(",",
CsvQuote(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")), CsvQuote(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")),
......
...@@ -16,22 +16,59 @@ namespace Tools.MyAgent ...@@ -16,22 +16,59 @@ namespace Tools.MyAgent
private static string LogDir => Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs"); private static string LogDir => Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "Logs");
/// <summary>追加一行日志(自动脱敏 + 时间戳)</summary> /// <summary>单个日志文件的轮转阈值(8MB,与观测 jsonl 一致 —— G6)。</summary>
private const long MaxLogBytes = 8L * 1024 * 1024;
/// <summary>追加一行 INFO 级日志(自动脱敏 + 时间戳 + [INF] 前缀)</summary>
public static void Log(string message) public static void Log(string message)
{
try { Write("INF", message); }
catch { }
}
/// <summary>追加一行 WARN 级日志(G6:日志分级)。</summary>
public static void LogWarning(string message)
{
try { Write("WRN", message); }
catch { }
}
/// <summary>追加一行 ERROR 级日志(G6:日志分级)。</summary>
public static void LogError(string message)
{
try { Write("ERR", message); }
catch { }
}
/// <summary>实际写行:时间戳 + [级别] + 脱敏正文。</summary>
private static void Write(string level, string message)
{ {
try try
{ {
lock (Lock) lock (Lock)
{ {
Directory.CreateDirectory(LogDir); Directory.CreateDirectory(LogDir);
string file = Path.Combine(LogDir, $"agent_{DateTime.Now:yyyyMMdd}.log"); string file = NextLogFile();
string line = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}\t{Mask(message)}\n"; string line = $"{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}\t[{level}]\t{Mask(message)}\n";
File.AppendAllText(file, line, Encoding.UTF8); File.AppendAllText(file, line, Encoding.UTF8);
} }
} }
catch { } catch { }
} }
/// <summary>当日日志文件(8MB 轮转:agent_yyyyMMdd.log → _1 → _2…)。</summary>
private static string NextLogFile()
{
string primary = Path.Combine(LogDir, $"agent_{DateTime.Now:yyyyMMdd}.log");
if (!File.Exists(primary) || new FileInfo(primary).Length < MaxLogBytes) return primary;
for (int i = 1; i < 100; i++)
{
string candidate = Path.Combine(LogDir, $"agent_{DateTime.Now:yyyyMMdd}_{i}.log");
if (!File.Exists(candidate) || new FileInfo(candidate).Length < MaxLogBytes) return candidate;
}
return primary;
}
/// <summary>模型调用记录(#29):model / token / 耗时 / 工具序列 / 结果</summary> /// <summary>模型调用记录(#29):model / token / 耗时 / 工具序列 / 结果</summary>
public static void LogCall(string model, int promptTokens, int completionTokens, long elapsedMs, string tools, string note, string traceId = "") public static void LogCall(string model, int promptTokens, int completionTokens, long elapsedMs, string tools, string note, string traceId = "")
{ {
......
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System; using System;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
...@@ -35,6 +36,9 @@ namespace Tools.MyAgent ...@@ -35,6 +36,9 @@ namespace Tools.MyAgent
/// <summary>提问哈希(SHA256 前 12 位;一轮算一次,非 hot path)。</summary> /// <summary>提问哈希(SHA256 前 12 位;一轮算一次,非 hot path)。</summary>
public string QuestionHash = ""; public string QuestionHash = "";
/// <summary>提问前 120 字(仅 detailed 级别填充;basic 恒空 —— §9 隐私分级)。</summary>
public string QuestionPreview = "";
/// <summary>配置模型。</summary> /// <summary>配置模型。</summary>
public string Model = ""; public string Model = "";
...@@ -98,6 +102,9 @@ namespace Tools.MyAgent ...@@ -98,6 +102,9 @@ namespace Tools.MyAgent
/// <summary>参数摘要(经 <see cref="AgentLogger.Mask"/> 后截断 80 字)。</summary> /// <summary>参数摘要(经 <see cref="AgentLogger.Mask"/> 后截断 80 字)。</summary>
public string ArgsSummary = ""; public string ArgsSummary = "";
/// <summary>工具返回前 200 字(仅 detailed 级别填充;basic 恒空 —— §9 隐私分级)。</summary>
public string ResultPreview = "";
/// <summary>相对本轮起点的偏移(ms)。</summary> /// <summary>相对本轮起点的偏移(ms)。</summary>
public long StartOffsetMs; public long StartOffsetMs;
...@@ -164,6 +171,13 @@ namespace Tools.MyAgent ...@@ -164,6 +171,13 @@ namespace Tools.MyAgent
public long CompletionTokens; public long CompletionTokens;
public double Cost; public double Cost;
public long Dropped; public long Dropped;
/// <summary>gauge:待落盘队列当前深度(观测自查)。</summary>
public long QueueDepth;
/// <summary>gauge:Logs 目录当日观测文件总字节数(runs/metrics jsonl,观测自查)。</summary>
public long LogSizeToday;
public HistogramView Duration = new HistogramView(); public HistogramView Duration = new HistogramView();
public HistogramView Ttfb = new HistogramView(); public HistogramView Ttfb = new HistogramView();
...@@ -219,7 +233,11 @@ namespace Tools.MyAgent ...@@ -219,7 +233,11 @@ namespace Tools.MyAgent
: "basic"; : "basic";
if (!string.IsNullOrEmpty(logsRoot)) _logsRoot = logsRoot; if (!string.IsNullOrEmpty(logsRoot)) _logsRoot = logsRoot;
} }
if (_enabled && !string.Equals(_level, "off", StringComparison.OrdinalIgnoreCase)) EnsureWriter(); if (_enabled && !string.Equals(_level, "off", StringComparison.OrdinalIgnoreCase))
{
ReplayTodayMetrics(); // P1-6:先重放(此刻内存≈0,合并=纯叠加),再启动写线程
EnsureWriter();
}
} }
catch { } catch { }
} }
...@@ -429,6 +447,8 @@ namespace Tools.MyAgent ...@@ -429,6 +447,8 @@ namespace Tools.MyAgent
if (kv.Key.StartsWith("agent_cost_total", StringComparison.OrdinalIgnoreCase)) costMilli += kv.Value; if (kv.Key.StartsWith("agent_cost_total", StringComparison.OrdinalIgnoreCase)) costMilli += kv.Value;
} }
st.Cost = costMilli / 10000.0; // 计数器以「毫分」存费用 st.Cost = costMilli / 10000.0; // 计数器以「毫分」存费用
st.QueueDepth = RunQueue.Count; // gauge:队列深度
st.LogSizeToday = LogsSizeToday();
return st; return st;
} }
catch { return new RunStats(); } catch { return new RunStats(); }
...@@ -441,6 +461,20 @@ namespace Tools.MyAgent ...@@ -441,6 +461,20 @@ namespace Tools.MyAgent
catch { return 0; } catch { return 0; }
} }
/// <summary>gauge:当日观测文件(runs/metrics jsonl)总字节数,供观测自查。</summary>
private static long LogsSizeToday()
{
try
{
long total = 0;
string today = DateTime.Now.ToString("yyyyMMdd");
foreach (var f in Directory.GetFiles(LogsRoot, "*" + today + "*.jsonl"))
total += new FileInfo(f).Length;
return total;
}
catch { return 0; }
}
/// <summary>按前缀汇总(用于无维度的总量)。</summary> /// <summary>按前缀汇总(用于无维度的总量)。</summary>
public static long SumPrefix(string prefix) public static long SumPrefix(string prefix)
{ {
...@@ -643,7 +677,7 @@ namespace Tools.MyAgent ...@@ -643,7 +677,7 @@ namespace Tools.MyAgent
catch { } catch { }
} }
/// <summary>落一条指标快照(计数器 + 直方图读数)。</summary> /// <summary>落一条指标快照(计数器 + 直方图读数;直方图带原始桶分布,供重启重放精确合并)。</summary>
public static void FlushMetrics() public static void FlushMetrics()
{ {
try try
...@@ -654,8 +688,8 @@ namespace Tools.MyAgent ...@@ -654,8 +688,8 @@ namespace Tools.MyAgent
{ {
["ts"] = DateTime.UtcNow.ToString("o"), ["ts"] = DateTime.UtcNow.ToString("o"),
["counters"] = new Dictionary<string, long>(Counters), ["counters"] = new Dictionary<string, long>(Counters),
["run_ms"] = Histogram("agent_run_duration_ms"), ["run_ms"] = HistSnapshot("agent_run_duration_ms"),
["ttfb_ms"] = Histogram("agent_ttfb_ms"), ["ttfb_ms"] = HistSnapshot("agent_ttfb_ms"),
}; };
string line = JsonConvert.SerializeObject(snap); string line = JsonConvert.SerializeObject(snap);
var w = GetMetricWriter(); var w = GetMetricWriter();
...@@ -664,6 +698,96 @@ namespace Tools.MyAgent ...@@ -664,6 +698,96 @@ namespace Tools.MyAgent
catch { } catch { }
} }
/// <summary>单个直方图的落盘形态:count/sum/min/max + 12 桶分布(P1-6 重放合并的数据源)。</summary>
private static object HistSnapshot(string key)
{
try
{
if (!Histograms.TryGetValue(key, out var h)) return new HistogramView();
lock (h)
{
return new
{
count = h.Total,
sum = h.Sum,
min = h.Min == long.MaxValue ? 0 : h.Min,
max = h.Max,
buckets = h.Counts,
};
}
}
catch { return new HistogramView(); }
}
/// <summary>
/// P1-6:启动时重放当日 metrics_*.jsonl 最后一条快照并合并进内存,
/// 使 <c>/stats</c> 与设置页 KPI 在进程重启后不丢当日累计(看板读 jsonl 本就不受影响)。
/// <para>合并语义:计数器 = 存量 + 当前(重放只在启动时做一次,此刻当前≈0);
/// 直方图按桶分布相加。旧格式快照(无 buckets)跳过直方图部分。</para>
/// </summary>
private static bool _replayed;
private static void ReplayTodayMetrics()
{
try
{
if (_replayed) return;
_replayed = true;
string path = Path.Combine(LogsRoot, "metrics_" + DateTime.Now.ToString("yyyyMMdd") + ".jsonl");
if (!File.Exists(path)) return;
string last = null;
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
using (var sr = new StreamReader(fs))
{
string l;
while ((l = sr.ReadLine()) != null)
if (!string.IsNullOrWhiteSpace(l)) last = l; // 取最后一条完整快照
}
if (string.IsNullOrEmpty(last)) return;
var o = JObject.Parse(last);
if (o["counters"] is JObject counters)
foreach (var kv in counters)
{
if (kv.Value == null || !kv.Value.Type.ToString().StartsWith("Integer")) continue;
long stored = (long)kv.Value;
if (stored <= 0) continue;
Counters.AddOrUpdate(kv.Key, stored, (_, cur) => cur + stored);
}
MergeHist(o["run_ms"], "agent_run_duration_ms");
MergeHist(o["ttfb_ms"], "agent_ttfb_ms");
}
catch { }
}
/// <summary>把一条落盘直方图快照按桶合并进内存(count/sum/min/max + buckets)。</summary>
private static void MergeHist(JToken snap, string key)
{
try
{
if (!(snap is JObject jo)) return;
var jBuckets = jo["buckets"] as JArray;
if (jBuckets == null) return; // 旧格式无桶分布,跳过(避免破坏分位数)
long count = (long?)jo["count"] ?? 0;
if (count <= 0) return;
long sum = (long?)jo["sum"] ?? 0;
long min = (long?)jo["min"] ?? 0;
long max = (long?)jo["max"] ?? 0;
var h = Histograms.GetOrAdd(key, _ => new Hist());
lock (h)
{
for (int i = 0; i < Buckets.Length && i < jBuckets.Count; i++)
h.Counts[i] += (long)jBuckets[i];
h.Total += count;
h.Sum += sum;
if (min < h.Min) h.Min = min;
if (max > h.Max) h.Max = max;
}
}
catch { }
}
/// <summary>把队列与缓冲落盘(进程退出 / 手动调用)。</summary> /// <summary>把队列与缓冲落盘(进程退出 / 手动调用)。</summary>
public static void Flush() public static void Flush()
{ {
......
...@@ -161,6 +161,7 @@ namespace Tools.MyAgent ...@@ -161,6 +161,7 @@ namespace Tools.MyAgent
_engine = new AgentEngine(_kb, _agentRoot, _productContext, diagnosticRoots); _engine = new AgentEngine(_kb, _agentRoot, _productContext, diagnosticRoots);
LoadConfig(); LoadConfig();
ApplyConfig(); ApplyConfig();
RunStartupHealthCheck(); // HealthCheckOnStart=true 时后台自检落 health.json(P1-4:原为死配置)
WireEvents(); WireEvents();
RefreshChat(); RefreshChat();
UpdateStatusIndicator(); UpdateStatusIndicator();
...@@ -433,6 +434,20 @@ namespace Tools.MyAgent ...@@ -433,6 +434,20 @@ namespace Tools.MyAgent
SaveAgentConfig(_config); SaveAgentConfig(_config);
} }
/// <summary>P1-4:HealthCheckOnStart=true 时启动后台被动自检并落 health.json(不做模型服务网络探测,避免拖慢启动)。</summary>
private void RunStartupHealthCheck()
{
if (!_config.HealthCheckOnStart) return;
var cfg = _config;
var kb = _kb;
var roots = _engine != null ? _engine.DiagnosticRoots : null;
System.Threading.Tasks.Task.Run(() =>
{
try { AgentHealth.Save(AgentHealth.Check(cfg, kb, roots)); }
catch { }
});
}
private void ApplyConfig() private void ApplyConfig()
{ {
if (_productContext != null) if (_productContext != null)
...@@ -2812,7 +2827,7 @@ namespace Tools.MyAgent ...@@ -2812,7 +2827,7 @@ namespace Tools.MyAgent
try try
{ {
int days = _config.DashboardDays > 0 && _config.DashboardDays <= 30 ? _config.DashboardDays : 7; int days = _config.DashboardDays > 0 && _config.DashboardDays <= 30 ? _config.DashboardDays : 7;
var form = new Tools.MyAgent.Forms.AgentAdminForm(null, days, AgentTelemetry.IsDetailed); var form = new Tools.MyAgent.Forms.AgentAdminForm(null, days, AgentTelemetry.IsDetailed, _config.AdminAutoRefresh);
form.Show(this); form.Show(this);
} }
catch (Exception ex) catch (Exception ex)
...@@ -2850,6 +2865,8 @@ namespace Tools.MyAgent ...@@ -2850,6 +2865,8 @@ namespace Tools.MyAgent
if (tokens > 0) sb.Append("- Token:").Append(st.PromptTokens).Append(" + ").Append(st.CompletionTokens).Append("\n"); if (tokens > 0) sb.Append("- Token:").Append(st.PromptTokens).Append(" + ").Append(st.CompletionTokens).Append("\n");
if (st.Cost > 0) sb.Append("- 费用:").Append(CostEstimator.Format(st.Cost)).Append("\n"); if (st.Cost > 0) sb.Append("- 费用:").Append(CostEstimator.Format(st.Cost)).Append("\n");
if (st.Dropped > 0) sb.Append("- ⚠ 丢弃 ").Append(st.Dropped).Append(" 条(采集跟不上)\n"); if (st.Dropped > 0) sb.Append("- ⚠ 丢弃 ").Append(st.Dropped).Append(" 条(采集跟不上)\n");
sb.Append("- 观测自查:待落盘 ").Append(st.QueueDepth).Append(" 条 · 当日 jsonl ")
.Append(st.LogSizeToday / 1024).Append(" KB\n");
if (st.Runs == 0) sb.Append("_暂无数据(本进程尚未完成任何一轮回答)_\n"); if (st.Runs == 0) sb.Append("_暂无数据(本进程尚未完成任何一轮回答)_\n");
AddSystemMessage(sb.ToString()); AddSystemMessage(sb.ToString());
} }
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment