Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
F
Ftt_Common
Project
Project
Details
Activity
Releases
Cycle Analytics
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Issues
0
Issues
0
List
Board
Labels
Milestones
Merge Requests
0
Merge Requests
0
CI / CD
CI / CD
Pipelines
Jobs
Schedules
Charts
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Open sidebar
Administrator
Ftt_Common
Commits
5b9144c4
Commit
5b9144c4
authored
Sep 24, 2026
by
段廷瑞
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
feat: Agent可观测性功能
parent
4230d763
Changes
16
Hide whitespace changes
Inline
Side-by-side
Showing
16 changed files
with
2888 additions
and
12 deletions
+2888
-12
Tools.Agent.dll
AppHome/Tools.Agent.dll
+0
-0
Tools.Agent.xml
AppHome/Tools.Agent.xml
+390
-2
AgentEngine.cs
Tools.Agent/Agent/AgentEngine.cs
+101
-7
OpenAiCompatibleClient.cs
Tools.Agent/Agent/OpenAiCompatibleClient.cs
+13
-0
AgentRuntime.cs
Tools.Agent/AgentRuntime.cs
+7
-0
AgentConfig.cs
Tools.Agent/Config/AgentConfig.cs
+31
-0
AgentAdminForm.cs
Tools.Agent/Forms/AgentAdminForm.cs
+518
-0
AgentSettingsForm.cs
Tools.Agent/Forms/AgentSettingsForm.cs
+120
-0
DashChart.cs
Tools.Agent/Forms/DashChart.cs
+170
-0
KbService.cs
Tools.Agent/Kb/KbService.cs
+12
-0
AgentHealth.cs
Tools.Agent/Support/AgentHealth.cs
+258
-0
AgentTelemetry.cs
Tools.Agent/Support/AgentTelemetry.cs
+703
-0
StorageCleanup.cs
Tools.Agent/Support/StorageCleanup.cs
+7
-1
TelemetryStore.cs
Tools.Agent/Support/TelemetryStore.cs
+413
-0
Tools.Agent.csproj
Tools.Agent/Tools.Agent.csproj
+10
-1
wMyAgent.cs
Tools.Agent/wMyAgent.cs
+135
-1
No files found.
AppHome/Tools.Agent.dll
View file @
5b9144c4
No preview for this file type
AppHome/Tools.Agent.xml
View file @
5b9144c4
...
...
@@ -43,6 +43,9 @@
<member
name=
"M:Tools.MyAgent.AgentRuntime.CreateEngine(Tools.MyAgent.Kb.KbService)"
>
<summary>
创建无 UI Agent 引擎。调用方负责配置、订阅事件和释放。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.AgentRuntime.CreateAdminForm(System.Int32,System.Boolean)"
>
<summary>
创建 Admin 看板窗体(供宿主挂到菜单/工具栏)。
</summary>
</member>
<member
name=
"T:Tools.MyAgent.AgentProductContext"
>
<summary>
宿主声明的产品上下文。产品身份来自注册表,项目路径由宿主按权限注入。
...
...
@@ -87,6 +90,9 @@
<member
name=
"F:Tools.MyAgent.Agent.AgentEngine._diagnosticToolHit"
>
<summary>
本轮是否调用过诊断类工具(P3 双契约判定的输入之一,随 RunAsync 重置)。
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.AgentEngine._runScope"
>
<summary>
本轮可观测性上下文(RunId 与
<c>
_diagnosticSessionId
</c>
同源)。
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Agent.AgentEngine._answerKind"
>
<summary>
本轮回答类型(P3 双契约);在 FinishAnswerAsync 开头按当前证据重算。
</summary>
</member>
...
...
@@ -280,7 +286,7 @@
<member
name=
"F:Tools.MyAgent.Agent.AgentEngine.FinalizePrompt"
>
<summary>
工具轮次/时长达到上限时的收尾指令:禁止再调工具,基于已收集信息汇总作答
</summary>
</member>
<member
name=
"M:Tools.MyAgent.Agent.AgentEngine.SendAndLogAsync(Newtonsoft.Json.Linq.JArray,Newtonsoft.Json.Linq.JArray,System.Threading.CancellationToken)"
>
<member
name=
"M:Tools.MyAgent.Agent.AgentEngine.SendAndLogAsync(Newtonsoft.Json.Linq.JArray,Newtonsoft.Json.Linq.JArray,System.Threading.CancellationToken
,System.String
)"
>
<summary>
发送(带故障转移)并记录运行日志;tools=null 表示不下发工具(收尾回答轮/续写轮)
</summary>
</member>
<member
name=
"M:Tools.MyAgent.Agent.AgentEngine.FinishAnswerAsync(Newtonsoft.Json.Linq.JArray,Tools.MyAgent.Agent.ChatResult,System.Text.StringBuilder,System.Threading.CancellationToken)"
>
...
...
@@ -378,6 +384,9 @@
改为保留头部 + 明确提示后,模型如需其余部分可缩小范围再取(如 kb_read 按 offset 续读)。
</para>
</summary>
</member>
<member
name=
"M:Tools.MyAgent.Agent.AgentEngine.ClassifyError(System.Exception)"
>
<summary>
把异常归类为可聚合的 ErrorType(观测用,不参与用户提示文案)。
</summary>
</member>
<member
name=
"T:Tools.MyAgent.Agent.FailoverPolicy"
>
<summary>
故障转移策略:主模型连续失败时自动切换到备用模型。
...
...
@@ -433,6 +442,9 @@
<member
name=
"P:Tools.MyAgent.Agent.ChatResult.PromptTokens"
>
<summary>
用量(SSE 末帧 usage;无则 -1)
</summary>
</member>
<member
name=
"P:Tools.MyAgent.Agent.ChatResult.TtfbMs"
>
<summary>
首包延迟(ms):发出请求到收到首个有效 delta;无数据为 0(M6,向后兼容)
</summary>
</member>
<member
name=
"T:Tools.MyAgent.Agent.OpenAiCompatibleClient"
>
<summary>
DeepSeek(OpenAI 兼容)聊天补全客户端,支持 SSE 流式 + function calling。
...
...
@@ -1114,6 +1126,33 @@
<para>
空字典/缺省 = 所有登录用户可用(保持现状);值为空串或 "*" = 不限制。
</para>
</summary>
</member>
<member
name=
"P:Tools.MyAgent.Config.AgentConfig.TelemetryEnabled"
>
<summary>
可观测性总开关;关闭则不写 jsonl、不建队列(纯文本日志照旧)。
</summary>
</member>
<member
name=
"P:Tools.MyAgent.Config.AgentConfig.ObservabilityLevel"
>
<summary>
隐私级别:
<c>
off
</c>
不采集 /
<c>
basic
</c>
结构化字段不写原文(默认) /
<c>
detailed
</c>
追加提问与工具返回摘要。
</summary>
</member>
<member
name=
"P:Tools.MyAgent.Config.AgentConfig.HealthCheckOnStart"
>
<summary>
启动时执行一次依赖自检(默认关闭,仅 /diag 与看板触发)。
</summary>
</member>
<member
name=
"P:Tools.MyAgent.Config.AgentConfig.RunsKeepDays"
>
<summary>
runs_*.jsonl 保留天数(默认 14)。
</summary>
</member>
<member
name=
"P:Tools.MyAgent.Config.AgentConfig.ShowRunSummary"
>
<summary>
回复尾注是否带本轮摘要(耗时/工具数/通道);默认关闭,避免打扰。
</summary>
</member>
<member
name=
"P:Tools.MyAgent.Config.AgentConfig.AdminEnabled"
>
<summary>
看板总开关;关闭时 /admin 提示不可用。
</summary>
</member>
<member
name=
"P:Tools.MyAgent.Config.AgentConfig.AdminRoles"
>
<summary>
允许打开看板的角色(逗号分隔);空 = 不限制(保持现状,不破坏既有宿主)。
</summary>
</member>
<member
name=
"P:Tools.MyAgent.Config.AgentConfig.DashboardDays"
>
<summary>
看板读取最近 N 天(1~30)。
</summary>
</member>
<member
name=
"P:Tools.MyAgent.Config.AgentConfig.AdminAutoRefresh"
>
<summary>
看板 30s 自动刷新(默认关闭,避免常驻定时器拖住宿主)。
</summary>
</member>
<member
name=
"T:Tools.MyAgent.Config.RoleTemplate"
>
<summary>
角色预定义模板条目(roles.json 中的一项;Prompt 由大模型预生成)
</summary>
</member>
...
...
@@ -1177,6 +1216,40 @@
<member
name=
"M:Tools.MyAgent.Config.SecretProtector.IsProtected(System.String)"
>
<summary>
判断存储值是否为已加密密文
</summary>
</member>
<member
name=
"T:Tools.MyAgent.Forms.AgentAdminForm"
>
<summary>
Admin 看板(管理面板):一屏看回答质量、成本、稳定性与知识缺口。
<para>
纯代码构建 UI(不配 Designer/resx)——本机无 VS 设计器,手写 Designer 易漏 InitializeComponent。
</para>
<para>
与聊天窗
<c>
/stats
</c>
<c>
/diag
</c>
互补:那两条是终端用户随口问,这里是管理者看趋势。
</para>
</summary>
</member>
<member
name=
"T:Tools.MyAgent.Forms.DashChart"
>
<summary>
看板自绘图表(柱状 / 横向条形)。零第三方依赖:GDI+ 直接画,约 120 行。
<para>
不开 DoubleBuffered 会闪;不画网格线会看不出量级 —— 两者都保留。
</para>
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Forms.DashChart.Labels"
>
<summary>
柱标签(如小时 "00".."23" 或工具名)。
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Forms.DashChart.Values"
>
<summary>
主序列数值。
</summary>
</member>
<member
name=
"F:Tools.MyAgent.Forms.DashChart.Values2"
>
<summary>
次序列数值(叠加,如失败数);可为空。
</summary>
</member>
<member
name=
"P:Tools.MyAgent.Forms.DashChart.Horizontal"
>
<summary>
是否横向条形图(工具排名用)。
</summary>
</member>
<member
name=
"P:Tools.MyAgent.Forms.DashChart.EmptyText"
>
<summary>
空数据占位文案。
</summary>
</member>
<member
name=
"P:Tools.MyAgent.Forms.DashChart.Caption"
>
<summary>
图表标题 / 坐标轴说明;非空时在顶部绘制白底说明条(避免与最高柱重叠)。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.Forms.DashChart.BarFrac(System.Double,System.Double)"
>
<summary>
柱高比例:0 → 0;其余按 log2 倍增刻度(每翻倍增量恒定),避免小计次不可见、大计次一家独大。
</summary>
</member>
<member
name=
"T:Tools.MyAgent.Forms.AgentHistoryForm"
>
<summary>
历史会话弹窗(从 wMyAgent.ShowHistoryPopup 迁出):搜索 / 加载 / 删除 / 重命名 / 导出 MD / 清理。
...
...
@@ -1244,6 +1317,18 @@
<member
name=
"M:Tools.MyAgent.Forms.AgentSettingsForm.BuildToolPermissionUi"
>
<summary>
在「可选能力」页追加工具权限输入区(角色白名单,一行一规则)
</summary>
</member>
<member
name=
"M:Tools.MyAgent.Forms.AgentSettingsForm.BuildDashboardTab"
>
<summary>
新增「统计看板」选项卡:实时进程用量 + 一键打开完整看板(5 Tab)。
<para>
看板本体仍是独立窗体 AgentAdminForm(1000×680 设计尺寸),此处仅做入口与即时 KPI,避免在窄设置页里硬塞大面板。
</para>
</summary>
</member>
<member
name=
"M:Tools.MyAgent.Forms.AgentSettingsForm.OpenDashboard"
>
<summary>
打开完整看板(与 /admin 同源,受 AdminEnabled 开关约束)。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.Forms.AgentSettingsForm.RefreshDashKpi"
>
<summary>
即时 KPI(与 /stats 同源:AgentTelemetry.Snapshot)。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.Forms.AgentSettingsForm.ToolRoleRulesToLines(System.Collections.Generic.Dictionary{System.String,System.String})"
>
<summary>
角色规则字典 → 编辑文本(排序保证稳定回显)
</summary>
</member>
...
...
@@ -1808,6 +1893,11 @@
<member
name=
"P:Tools.MyAgent.Kb.KbService.IsSearchValid"
>
<summary>
搜索范围是否有效(目录存在)
</summary>
</member>
<member
name=
"P:Tools.MyAgent.Kb.KbService.IndexReady"
>
<summary>
索引是否已就绪(未启动 / 构建中为 false)。供可观测性健康检查读取,不改变任何检索行为。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.Kb.KbService.EnsureIndexStartedIfSmall(System.Int32)"
>
<summary>
仅为小型知识库自动启动索引。文件数未知或超过阈值时保持未建索引,避免打开窗口触发大规模 SQLite 建库。
...
...
@@ -2189,6 +2279,275 @@
<member
name=
"M:Tools.MyAgent.AgentLogger.Mask(System.String)"
>
<summary>
敏感信息脱敏(#41):手机号 / 邮箱 / API Key / 密码 / 令牌 / 长串
</summary>
</member>
<member
name=
"T:Tools.MyAgent.AgentTelemetry"
>
<summary>
智能体可观测性出口(Trace / Metrics)。
<para>
红线:本类所有 public 方法体在最外层 try/catch —— **不得有向上传播的 throw**,
观测任何异常都不得影响正常回答。
</para>
<para>
与
<see
cref=
"T:Tools.MyAgent.AgentLogger"
/>
的分工:Logger 写「给人看的文本日志」;
本类写「可聚合的结构化数据」(一轮一条 RunScope + 计数器/直方图)。
</para>
</summary>
</member>
<member
name=
"T:Tools.MyAgent.AgentTelemetry.RunScope"
>
<summary>
一轮提问的运行时容器(对应一次
<c>
AgentEngine.RunAsync
</c>
)。
</summary>
</member>
<member
name=
"F:Tools.MyAgent.AgentTelemetry.RunScope.RunId"
>
<summary>
运行唯一标识(复用引擎的
<c>
_diagnosticSessionId
</c>
)。
</summary>
</member>
<member
name=
"F:Tools.MyAgent.AgentTelemetry.RunScope.StartedAt"
>
<summary>
起始时间(ISO8601 UTC)。
</summary>
</member>
<member
name=
"F:Tools.MyAgent.AgentTelemetry.RunScope.QuestionChars"
>
<summary>
提问字符数(不存原文)。
</summary>
</member>
<member
name=
"F:Tools.MyAgent.AgentTelemetry.RunScope.QuestionHash"
>
<summary>
提问哈希(SHA256 前 12 位;一轮算一次,非 hot path)。
</summary>
</member>
<member
name=
"F:Tools.MyAgent.AgentTelemetry.RunScope.Model"
>
<summary>
配置模型。
</summary>
</member>
<member
name=
"F:Tools.MyAgent.AgentTelemetry.RunScope.ServedModel"
>
<summary>
实际服务模型(故障转移时为备用模型)。
</summary>
</member>
<member
name=
"F:Tools.MyAgent.AgentTelemetry.RunScope.Mode"
>
<summary>
回答通道:kb / mixed / model / tool-loop。
</summary>
</member>
<member
name=
"F:Tools.MyAgent.AgentTelemetry.RunScope.AnswerKind"
>
<summary>
回答类型(
<c>
AnswerSpec.Classify
</c>
结果)。
</summary>
</member>
<member
name=
"F:Tools.MyAgent.AgentTelemetry.RunScope.TtfbMs"
>
<summary>
首包延迟(首次 LLM 调用的首个有效 delta)。
</summary>
</member>
<member
name=
"F:Tools.MyAgent.AgentTelemetry.RunScope.Outcome"
>
<summary>
success / error / cancel。
</summary>
</member>
<member
name=
"F:Tools.MyAgent.AgentTelemetry.RunScope.ErrorType"
>
<summary>
auth|quota|ratelimit|network|context_length|timeout|unknown。
</summary>
</member>
<member
name=
"F:Tools.MyAgent.AgentTelemetry.RunScope.Completed"
>
<summary>
是否走到自然收尾(未被取消/异常中断)。
</summary>
</member>
<member
name=
"F:Tools.MyAgent.AgentTelemetry.RunScope.SpansTruncated"
>
<summary>
span 达到上限被截断采集(ToolSpan ≤ 60 / LlmSpan ≤ 20)。
</summary>
</member>
<member
name=
"T:Tools.MyAgent.AgentTelemetry.ToolSpan"
>
<summary>
一次工具调用(对
<c>
AgentEngine.ToolTraceEntry
</c>
的超集,不替换它)。
</summary>
</member>
<member
name=
"F:Tools.MyAgent.AgentTelemetry.ToolSpan.ArgsSummary"
>
<summary>
参数摘要(经
<see
cref=
"M:Tools.MyAgent.AgentLogger.Mask(System.String)"
/>
后截断 80 字)。
</summary>
</member>
<member
name=
"F:Tools.MyAgent.AgentTelemetry.ToolSpan.StartOffsetMs"
>
<summary>
相对本轮起点的偏移(ms)。
</summary>
</member>
<member
name=
"F:Tools.MyAgent.AgentTelemetry.ToolSpan.Outcome"
>
<summary>
ok / empty / error / timeout / cancel / denied。
</summary>
</member>
<member
name=
"F:Tools.MyAgent.AgentTelemetry.ToolSpan.Truncated"
>
<summary>
结果被
<c>
CompressToolResult
</c>
截断。
</summary>
</member>
<member
name=
"T:Tools.MyAgent.AgentTelemetry.LlmSpan"
>
<summary>
一次 LLM 请求(含工具轮 / 收尾 / 续写 / 直答 / 标题 / 摘要)。
</summary>
</member>
<member
name=
"F:Tools.MyAgent.AgentTelemetry.LlmSpan.Kind"
>
<summary>
tool-round / finish / continuation / direct / title / summarize。
</summary>
</member>
<member
name=
"T:Tools.MyAgent.AgentTelemetry.HistogramView"
>
<summary>
直方图读数(固定桶插值,±半桶误差)。
</summary>
</member>
<member
name=
"T:Tools.MyAgent.AgentTelemetry.RunStats"
>
<summary>
内存快照(
<c>
/stats
</c>
与看板读这个)。
</summary>
</member>
<member
name=
"F:Tools.MyAgent.AgentTelemetry.RunStats.Counters"
>
<summary>
计数器全量副本(键形如
<c>
agent_runs_total|outcome=success
</c>
)。
</summary>
</member>
<member
name=
"F:Tools.MyAgent.AgentTelemetry.Buckets"
>
<summary>
直方图桶上界(ms):50/100/200/400/800/1600/3200/6400/12800/25600/51200/+∞
</summary>
</member>
<member
name=
"P:Tools.MyAgent.AgentTelemetry.Level"
>
<summary>
隐私级别:off / basic / detailed。
</summary>
</member>
<member
name=
"P:Tools.MyAgent.AgentTelemetry.IsDetailed"
>
<summary>
是否写入提问原文类数据(仅 detailed)。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.AgentTelemetry.Configure(System.Boolean,System.String,System.String)"
>
<summary>
应用配置。任何非法值一律回退默认,不抛异常。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.AgentTelemetry.BeginRun(System.Int32,System.String,System.String)"
>
<summary>
开启一轮运行;关闭观测时返回 null(调用方须判空,用
<c>
??
</c>
安全调用)。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.AgentTelemetry.EndRun(Tools.MyAgent.AgentTelemetry.RunScope,System.String,System.String,System.Exception)"
>
<summary>
结束一轮运行:结算耗时、累计指标、触发
<see
cref=
"E:Tools.MyAgent.AgentTelemetry.OnRunCompleted"
/>
。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.AgentTelemetry.RecordTool(Tools.MyAgent.AgentTelemetry.RunScope,Tools.MyAgent.AgentTelemetry.ToolSpan)"
>
<summary>
记录一次工具调用(
<c>
ExecuteTool
</c>
/
<c>
ExecuteToolAsync
</c>
调用)。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.AgentTelemetry.RecordLlm(Tools.MyAgent.AgentTelemetry.RunScope,Tools.MyAgent.AgentTelemetry.LlmSpan)"
>
<summary>
记录一次 LLM 请求(
<c>
SendAndLogAsync
</c>
调用)。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.AgentTelemetry.Count(System.String,System.Int64)"
>
<summary>
计数器累加。
<paramref
name=
"name"
/>
必须由埋点**硬编码**传入,
禁止外部字符串拼接(防止把路径/提问塞进维度导致维度爆炸)。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.AgentTelemetry.Observe(System.String,System.Int64)"
>
<summary>
观测一次耗时(ms)到固定桶直方图。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.AgentTelemetry.Histogram(System.String)"
>
<summary>
直方图读数:P50/P95 由固定桶线性插值得到(±半桶误差)。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.AgentTelemetry.Snapshot"
>
<summary>
内存快照(/stats 与看板读这个)。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.AgentTelemetry.Get(System.String)"
>
<summary>
取计数器值(不存在返回 0)。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.AgentTelemetry.SumPrefix(System.String)"
>
<summary>
按前缀汇总(用于无维度的总量)。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.AgentTelemetry.HashQuestion(System.String)"
>
<summary>
提问哈希(SHA256 前 12 位 hex);一轮算一次,失败返回空串。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.AgentTelemetry.Summarize(System.String,System.Int32)"
>
<summary>
参数摘要:脱敏后截断(默认 80 字)。
</summary>
</member>
<member
name=
"F:Tools.MyAgent.AgentTelemetry.QueueCapacity"
>
<summary>
队列容量;满即丢弃并计数,绝不阻塞回答。
</summary>
</member>
<member
name=
"F:Tools.MyAgent.AgentTelemetry.MaxFileBytes"
>
<summary>
单个 jsonl 的轮转阈值(8MB)。
</summary>
</member>
<member
name=
"P:Tools.MyAgent.AgentTelemetry.LogsRoot"
>
<summary>
观测数据目录(默认 程序基目录/Logs)。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.AgentTelemetry.GetRunWriter"
>
<summary>
追加写(复用常驻 StreamWriter,避免每次 AppendAllText 的开合开销)。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.AgentTelemetry.NextAvailable(System.String)"
>
<summary>
超过阈值时加 _N 后缀轮转。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.AgentTelemetry.EnqueueRun(Tools.MyAgent.AgentTelemetry.RunScope)"
>
<summary>
一轮运行入队(满则丢弃并计数)。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.AgentTelemetry.FlushMetrics"
>
<summary>
落一条指标快照(计数器 + 直方图读数)。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.AgentTelemetry.Flush"
>
<summary>
把队列与缓冲落盘(进程退出 / 手动调用)。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.AgentTelemetry.Shutdown"
>
<summary>
停止写线程并落盘(宿主释放 / 关闭观测时调用;进程退出另有兜底)。
</summary>
</member>
<member
name=
"E:Tools.MyAgent.AgentTelemetry.OnRunCompleted"
>
<summary>
一轮运行完成(实时事件)。
</summary>
</member>
<member
name=
"E:Tools.MyAgent.AgentTelemetry.OnToolSpan"
>
<summary>
一次工具调用完成(实时事件,替代
<c>
OnToolStatus
</c>
的字符串)。
</summary>
</member>
<member
name=
"T:Tools.MyAgent.HealthItem"
>
<summary>
健康检查结果项。
</summary>
</member>
<member
name=
"F:Tools.MyAgent.HealthItem.Id"
>
<summary>
检查项标识(稳定,便于聚合与对比)。
</summary>
</member>
<member
name=
"F:Tools.MyAgent.HealthItem.Name"
>
<summary>
展示名。
</summary>
</member>
<member
name=
"F:Tools.MyAgent.HealthItem.Status"
>
<summary>
ok / warn / fail。
</summary>
</member>
<member
name=
"F:Tools.MyAgent.HealthItem.Detail"
>
<summary>
判定依据或处置建议。
</summary>
</member>
<member
name=
"T:Tools.MyAgent.AgentHealth"
>
<summary>
依赖健康检查(9 项)。按需执行,不定时轮询;单项异常只影响该项。
<para>
输出:
<c>
Logs/health.json
</c>
+ 聊天窗
<c>
/diag
</c>
的表格 + Admin 看板 ⑤Tab。
</para>
</summary>
</member>
<member
name=
"F:Tools.MyAgent.AgentHealth.LogsWarnBytes"
>
<summary>
日志目录超该大小时告警(防止观测自身撑爆磁盘)。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.AgentHealth.Check(Tools.MyAgent.Config.AgentConfig,Tools.MyAgent.Kb.KbService,System.Collections.Generic.IReadOnlyList{Tools.MyAgent.Diagnostics.DiagnosticRoot})"
>
<summary>
执行检查(不含模型服务连通性——它是异步的,由调用方
<see
cref=
"M:Tools.MyAgent.AgentHealth.ModelService(System.Boolean,System.String)"
/>
单独追加)。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.AgentHealth.ModelService(System.Boolean,System.String)"
>
<summary>
模型服务连通性项(由调用方用
<c>
TestConnectionAsync
</c>
结果构造)。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.AgentHealth.CheckTelemetrySelf"
>
<summary>
观测自身:日志目录是否膨胀(自查项,防止观测把磁盘撑爆)。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.AgentHealth.ToJson(Tools.MyAgent.HealthItem[])"
>
<summary>
序列化为 JSON(
<c>
Logs/health.json
</c>
)。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.AgentHealth.Save(Tools.MyAgent.HealthItem[])"
>
<summary>
落盘到
<c>
Logs/health.json
</c>
(覆盖写);失败不影响调用方。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.AgentHealth.RenderMarkdown(Tools.MyAgent.HealthItem[])"
>
<summary>
渲染为聊天窗 Markdown(分组表格;
<c>
/diag
</c>
用)。
</summary>
</member>
<member
name=
"T:Tools.MyAgent.TelemetryStore"
>
<summary>
可观测性只读聚合层(Admin 看板数据源)。与写侧
<see
cref=
"T:Tools.MyAgent.AgentTelemetry"
/>
分离:
写侧不因看板需求膨胀,看板想怎么查是看板的事。
<para>
护栏:只读最近 N 天 / 单文件最多 N 行 / 坏行跳过并计数 / 全程不允许抛异常。
</para>
</summary>
</member>
<member
name=
"T:Tools.MyAgent.TelemetryStore.RunRow"
>
<summary>
一行运行记录(看板表格的行模型)。
</summary>
</member>
<member
name=
"F:Tools.MyAgent.TelemetryStore.RunRow.Tools"
>
<summary>
工具 span 明细(下钻用;受
<see
cref=
"P:Tools.MyAgent.TelemetryStore.AllowDetailed"
/>
约束)。
</summary>
</member>
<member
name=
"T:Tools.MyAgent.TelemetryStore.ToolSpanView"
>
<summary>
一条工具 span(下钻展示)。
</summary>
</member>
<member
name=
"T:Tools.MyAgent.TelemetryStore.ToolAgg"
>
<summary>
按工具名聚合。
</summary>
</member>
<member
name=
"T:Tools.MyAgent.TelemetryStore.DailyAgg"
>
<summary>
按天聚合。
</summary>
</member>
<member
name=
"T:Tools.MyAgent.TelemetryStore.ModeAgg"
>
<summary>
召回通道分布(直答率来源)。
</summary>
</member>
<member
name=
"P:Tools.MyAgent.TelemetryStore.AllowDetailed"
>
<summary>
是否允许展示详细字段(参数摘要等)。由宿主按角色判定后注入。
</summary>
</member>
<member
name=
"P:Tools.MyAgent.TelemetryStore.BadLines"
>
<summary>
坏行计数(>5% 时看板顶部提示日志疑似损坏)。
</summary>
</member>
<member
name=
"P:Tools.MyAgent.TelemetryStore.ReadLines"
>
<summary>
已读取行数。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.TelemetryStore.LoadRuns"
>
<summary>
读取最近 N 天的运行记录(倒序:最新在前)。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.TelemetryStore.ReadTailLines(System.String,System.Int32)"
>
<summary>
读文件末尾 N 行(环形缓冲,避免大文件整体入内存)。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.TelemetryStore.AggregateTools(Tools.MyAgent.TelemetryStore.RunRow[])"
>
<summary>
按工具名聚合(调用数 / 失败率 / P50 / P95 / 超时 / 截断)。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.TelemetryStore.AggregateDaily(Tools.MyAgent.TelemetryStore.RunRow[],System.Int32)"
>
<summary>
按天聚合。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.TelemetryStore.AggregateModes(Tools.MyAgent.TelemetryStore.RunRow[])"
>
<summary>
召回通道分布。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.TelemetryStore.AggregateErrors(Tools.MyAgent.TelemetryStore.RunRow[],System.Int32)"
>
<summary>
错误类型分布(Top N)。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.TelemetryStore.RecentErrors(Tools.MyAgent.TelemetryStore.RunRow[],System.Int32)"
>
<summary>
最近 N 条错误(看板 ⑤Tab)。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.TelemetryStore.Hourly(Tools.MyAgent.TelemetryStore.RunRow[],System.Int64[]@)"
>
<summary>
24 小时分布:按小时统计成功/失败(概览柱状图)。
</summary>
</member>
<member
name=
"T:Tools.MyAgent.AgentUtils"
>
<summary>
智能体专用小工具(迁移自 Tools.MyUtils.Utils 中智能体依赖的方法)
...
...
@@ -2758,6 +3117,9 @@
<member
name=
"F:Tools.MyAgent.StorageCleanup.LogKeepDays"
>
<summary>
日志类文件保留天数(agent 日志 / search_misses / db 审计 / tool_results)
</summary>
</member>
<member
name=
"F:Tools.MyAgent.StorageCleanup.RunsKeepDays"
>
<summary>
可观测性 runs_*.jsonl 保留天数(比纯日志短:jsonl 更占空间)
</summary>
</member>
<member
name=
"F:Tools.MyAgent.StorageCleanup.ImageKeepDays"
>
<summary>
中间产物保留天数(kb_view_image 扫描件图片,可再生)
</summary>
</member>
...
...
@@ -2767,12 +3129,13 @@
<member
name=
"F:Tools.MyAgent.StorageCleanup.OutputKeepDays"
>
<summary>
正式产出保留天数(kb_write / doc_export)
</summary>
</member>
<member
name=
"M:Tools.MyAgent.StorageCleanup.Run(System.String,System.String)"
>
<member
name=
"M:Tools.MyAgent.StorageCleanup.Run(System.String,System.String
,System.Int32
)"
>
<summary>
执行清理(启动 / 配置变更时调用;内部容错,单项失败不影响其他)。
</summary>
<param
name=
"agentRoot"
>
默认 Agent 目录(output 位于其下);日志目录按程序基目录 Logs 计算
</param>
<param
name=
"logsRoot"
>
日志根目录(默认 程序基目录/Logs;测试可注入临时目录)
</param>
<param
name=
"runsKeepDays"
>
可观测性 runs_*.jsonl 的保留天数(默认
<see
cref=
"F:Tools.MyAgent.StorageCleanup.RunsKeepDays"
/>
)
</param>
</member>
<member
name=
"M:Tools.MyAgent.StorageCleanup.CleanDir(System.String,System.String,System.Int32,System.DateTime)"
>
<summary>
删除目录内超过保留期的文件;返回删除数(单项失败跳过)
</summary>
...
...
@@ -3014,6 +3377,12 @@
<member
name=
"M:Tools.MyAgent.wMyAgent.BuildReplyUsageSuffix"
>
<summary>
构造本轮回复用量标注(相对发送前快照的增量;本轮无调用记录返回空串)
</summary>
</member>
<member
name=
"F:Tools.MyAgent.wMyAgent._lastRunSummary"
>
<summary>
最近一轮的运行摘要(订阅
<c>
AgentTelemetry.OnRunCompleted
</c>
取得)
</summary>
</member>
<member
name=
"M:Tools.MyAgent.wMyAgent.OnRunCompleted(Tools.MyAgent.AgentTelemetry.RunScope)"
>
<summary>
M4:一轮运行完成 → 生成摘要文本(供回复尾注使用)
</summary>
</member>
<member
name=
"M:Tools.MyAgent.wMyAgent.OnToolTrace(System.Collections.Generic.IReadOnlyList{Tools.MyAgent.Agent.AgentEngine.ToolTraceEntry})"
>
<summary>
工具轨迹(#16):回答末尾追加可折叠 details,展示工具调用链(先宽后窄的检索过程)
</summary>
</member>
...
...
@@ -3222,6 +3591,25 @@
<summary>
全部可用斜杠命令(命令菜单按此列表展示:命令 + 参数 + 说明)
</summary>
</member>
<!-- Badly formed XML comment ignored for member "M:Tools.MyAgent.wMyAgent.RunKbQuickSearch(System.String)" -->
<member
name=
"M:Tools.MyAgent.wMyAgent.OpenAdmin"
>
<summary>
/admin:打开 Admin 看板。
<para>
权限:
<c>
AdminRoles
</c>
为空 = 不限制(保持现状,不破坏既有宿主);
仅当隐私级别为 detailed 时才把参数摘要传给看板。
</para>
</summary>
</member>
<member
name=
"M:Tools.MyAgent.wMyAgent.IsAdminRole"
>
<summary>
角色判定:与
<c>
ToolRoleAllow
</c>
同一套语义(当前登录用户即角色)。
</summary>
</member>
<member
name=
"M:Tools.MyAgent.wMyAgent.RunStats"
>
<summary>
/stats:本次进程累计(多日趋势见 Admin 看板)
</summary>
</member>
<member
name=
"M:Tools.MyAgent.wMyAgent.RunDiagnostics"
>
<summary>
/diag:依赖健康检查(9 项) + 本次进程累计用量
</summary>
</member>
<member
name=
"M:Tools.MyAgent.wMyAgent.BuildDiagUsage"
>
<summary>
/diag 的用量段(进程内累计;多日趋势见 Admin 看板)
</summary>
</member>
<!-- Badly formed XML comment ignored for member "M:Tools.MyAgent.wMyAgent.SwitchModel(System.String)" -->
<!-- Badly formed XML comment ignored for member "M:Tools.MyAgent.wMyAgent.SwitchScope(System.String)" -->
<member
name=
"M:Tools.MyAgent.wMyAgent.BackupAgentData"
>
...
...
Tools.Agent/Agent/AgentEngine.cs
View file @
5b9144c4
...
...
@@ -53,6 +53,9 @@ namespace Tools.MyAgent.Agent
/// <summary>本轮是否调用过诊断类工具(P3 双契约判定的输入之一,随 RunAsync 重置)。</summary>
private
bool
_diagnosticToolHit
;
/// <summary>本轮可观测性上下文(RunId 与 <c>_diagnosticSessionId</c> 同源)。</summary>
private
AgentTelemetry
.
RunScope
_runScope
;
/// <summary>本轮回答类型(P3 双契约);在 FinishAnswerAsync 开头按当前证据重算。</summary>
private
AnswerKind
_answerKind
=
AnswerKind
.
Knowledge
;
...
...
@@ -300,6 +303,7 @@ namespace Tools.MyAgent.Agent
_recall
.
MaxImages
=
cfg
.
MaxAnswerImages
>
0
?
Math
.
Min
(
cfg
.
MaxAnswerImages
,
16
)
:
16
;
CostEstimator
.
Configure
(
cfg
.
CustomPrices
);
// R6 费用估算:应用自定义价格表覆盖
AgentTelemetry
.
Configure
(
cfg
.
TelemetryEnabled
,
cfg
.
ObservabilityLevel
);
// 可观测性开关与隐私级别
// P0 连接串脱敏 / P3 工具权限:预存连接与角色规则随配置注入
DbConnections
=
cfg
.
DbConnections
??
new
Dictionary
<
string
,
string
>(
StringComparer
.
OrdinalIgnoreCase
);
_toolRoleAllow
=
cfg
.
ToolRoleAllow
??
new
Dictionary
<
string
,
string
>(
StringComparer
.
OrdinalIgnoreCase
);
...
...
@@ -350,6 +354,8 @@ namespace Tools.MyAgent.Agent
_diagnosticToolHit
=
false
;
_answerKind
=
AnswerKind
.
Knowledge
;
_currentQuestion
=
LastUserQuestion
(
history
);
_runScope
=
AgentTelemetry
.
BeginRun
(
_currentQuestion
.
Length
,
AgentTelemetry
.
HashQuestion
(
_currentQuestion
),
_client
.
Model
);
if
(
_runScope
!=
null
)
_runScope
.
RunId
=
_diagnosticSessionId
;
var
messages
=
await
BuildMessagesAsync
(
history
,
_cts
.
Token
).
ConfigureAwait
(
false
);
var
tools
=
BuildTools
();
var
stopwatch
=
Stopwatch
.
StartNew
();
...
...
@@ -368,7 +374,7 @@ namespace Tools.MyAgent.Agent
{
_cts
.
Token
.
ThrowIfCancellationRequested
();
var
result
=
await
SendAndLogAsync
(
messages
,
tools
,
_cts
.
Token
).
ConfigureAwait
(
false
);
var
result
=
await
SendAndLogAsync
(
messages
,
tools
,
_cts
.
Token
,
"tool-round"
).
ConfigureAwait
(
false
);
// 思维链
if
(
result
.
ReasoningContent
.
Length
>
0
)
...
...
@@ -428,7 +434,7 @@ namespace Tools.MyAgent.Agent
:
"单次回答总时长达到上限,启动收尾回答轮"
);
OnTextDelta
?.
Invoke
(
$"\n\n(
{(
roundsUp
?
$"已达到工具调用轮次上限(
{
_maxToolRounds
}
轮)"
:
"已达到单次回答总时长上限"
)}
,正在基于已收集的信息汇总回答…)"
);
messages
.
Add
(
new
JObject
{
[
"role"
]
=
"user"
,
[
"content"
]
=
FinalizePrompt
});
var
final
=
await
SendAndLogAsync
(
messages
,
null
,
_cts
.
Token
).
ConfigureAwait
(
false
);
var
final
=
await
SendAndLogAsync
(
messages
,
null
,
_cts
.
Token
,
"finish"
).
ConfigureAwait
(
false
);
if
(
final
.
ReasoningContent
.
Length
>
0
)
OnThinkingDelta
?.
Invoke
(
final
.
ReasoningContent
);
await
FinishAnswerAsync
(
messages
,
final
,
finalAnswer
,
_cts
.
Token
).
ConfigureAwait
(
false
);
...
...
@@ -438,6 +444,7 @@ namespace Tools.MyAgent.Agent
if
(!
IsDisposed
)
{
AgentTelemetry
.
EndRun
(
_runScope
,
"success"
,
""
,
null
);
OnComplete
?.
Invoke
();
if
(
_toolTrace
.
Count
>
0
)
OnToolTrace
?.
Invoke
(
_toolTrace
.
ToArray
());
...
...
@@ -445,17 +452,32 @@ namespace Tools.MyAgent.Agent
}
catch
(
OperationCanceledException
)
{
AgentTelemetry
.
EndRun
(
_runScope
,
"cancel"
,
""
,
null
);
if
(!
IsDisposed
)
{
// G2 修复:取消/异常路径此前只调 OnError,工具轨迹被丢弃 —— 失败恰恰最需要轨迹。
// ⚠ 顺序不可颠倒:宿主 OnError 会用错误文本整段覆盖 last.Content,
// 先发轨迹会被覆盖吃掉 ⇒ 必须先 OnError,再追加轨迹。
OnError
?.
Invoke
(
"已取消。"
);
if
(
_toolTrace
.
Count
>
0
)
OnToolTrace
?.
Invoke
(
_toolTrace
.
ToArray
());
}
}
catch
(
Exception
ex
)
{
AgentTelemetry
.
EndRun
(
_runScope
,
"error"
,
ClassifyError
(
ex
),
ex
);
if
(!
IsDisposed
)
{
// 同取消路径:先写错误文本,再追加轨迹(顺序颠倒会被覆盖)
OnError
?.
Invoke
(
DescribeError
(
ex
));
if
(
_toolTrace
.
Count
>
0
)
OnToolTrace
?.
Invoke
(
_toolTrace
.
ToArray
());
}
}
finally
{
IsRunning
=
false
;
_runScope
=
null
;
try
{
_cts
?.
Dispose
();
}
catch
{
}
_cts
=
null
;
if
(!
IsDisposed
)
...
...
@@ -487,6 +509,8 @@ namespace Tools.MyAgent.Agent
AgentLogger
.
Log
(
"本地召回异常,回退工具循环:"
+
ex
.
Message
);
return
false
;
}
if
(
_runScope
!=
null
&&
recall
!=
null
&&
!
string
.
IsNullOrEmpty
(
recall
.
Mode
))
_runScope
.
Mode
=
recall
.
Mode
;
// kb / mixed / model(后续走工具循环时 Mode 仍记为 model)
if
(
recall
==
null
||
recall
.
Primary
==
null
||
recall
.
Mode
==
"model"
)
return
false
;
// 证据注入按「先记账、失败可回滚」处理:直答失败要还原 messages,
...
...
@@ -502,7 +526,7 @@ namespace Tools.MyAgent.Agent
AgentLogger
.
Log
(
$"本地召回命中:mode=
{
recall
.
Mode
}
score=
{
recall
.
Primary
.
Score
:
0.00
}
file=
{
recall
.
Primary
.
RelPath
}
kw=
{
string
.
Join
(
"|"
,
recall
.
Keywords
)}
"
);
var
result
=
await
SendAndLogAsync
(
messages
,
null
,
ct
).
ConfigureAwait
(
false
);
var
result
=
await
SendAndLogAsync
(
messages
,
null
,
ct
,
"direct"
).
ConfigureAwait
(
false
);
if
(
result
.
ReasoningContent
.
Length
>
0
)
OnThinkingDelta
?.
Invoke
(
result
.
ReasoningContent
);
...
...
@@ -603,7 +627,7 @@ namespace Tools.MyAgent.Agent
"本次的工具调用配额已用完,禁止再发起任何工具调用。请基于以上已收集到的全部信息,直接、完整地回答用户的问题:先给结论,再展开依据与细节,引用处标注来源路径;已收集信息未覆盖的内容明确标注「未充分核实」。直接开始回答。"
;
/// <summary>发送(带故障转移)并记录运行日志;tools=null 表示不下发工具(收尾回答轮/续写轮)</summary>
private
async
Task
<
ChatResult
>
SendAndLogAsync
(
JArray
messages
,
JArray
tools
,
CancellationToken
ct
)
private
async
Task
<
ChatResult
>
SendAndLogAsync
(
JArray
messages
,
JArray
tools
,
CancellationToken
ct
,
string
kind
=
"tool-round"
)
{
var
start
=
DateTime
.
UtcNow
;
var
result
=
await
SendWithFailoverAsync
(
messages
,
tools
,
ct
).
ConfigureAwait
(
false
);
...
...
@@ -621,6 +645,20 @@ namespace Tools.MyAgent.Agent
string
.
Join
(
";"
,
result
.
ToolCalls
.
Select
(
tc
=>
tc
.
Name
+
"("
+
Clip
(
tc
.
Arguments
,
40
)
+
")"
)),
result
.
FinishReason
,
_diagnosticSessionId
);
// 可观测性:一次 LLM 请求 span(M6 接入 TtfbMs 前该字段恒为 0)
AgentTelemetry
.
RecordLlm
(
_runScope
,
new
AgentTelemetry
.
LlmSpan
{
RunId
=
_diagnosticSessionId
,
Kind
=
kind
,
Model
=
string
.
IsNullOrEmpty
(
_lastServedModel
)
?
_client
.
Model
:
_lastServedModel
,
DurationMs
=
(
long
)(
DateTime
.
UtcNow
-
start
).
TotalMilliseconds
,
PromptTokens
=
result
.
PromptTokens
>
0
?
result
.
PromptTokens
:
0
,
CompletionTokens
=
result
.
CompletionTokens
>
0
?
result
.
CompletionTokens
:
0
,
FinishReason
=
result
.
FinishReason
??
""
,
Outcome
=
"success"
,
TtfbMs
=
result
.
TtfbMs
,
// M6:首包延迟(未启用/非流式时为 0)
});
return
result
;
}
...
...
@@ -649,7 +687,7 @@ namespace Tools.MyAgent.Agent
AppendContinuationMessages
(
messages
,
result
);
OnTextDelta
?.
Invoke
(
finalAnswer
.
ToString
());
AgentLogger
.
Log
(
$"回答被输出上限(max_tokens)截断,自动续写第
{
_continueRounds
}
/
{
MaxContinuationRounds
}
轮"
);
result
=
await
SendAndLogAsync
(
messages
,
null
,
ct
).
ConfigureAwait
(
false
);
result
=
await
SendAndLogAsync
(
messages
,
null
,
ct
,
"continuation"
).
ConfigureAwait
(
false
);
if
(
result
.
ReasoningContent
.
Length
>
0
)
OnThinkingDelta
?.
Invoke
(
result
.
ReasoningContent
);
continue
;
...
...
@@ -694,6 +732,7 @@ namespace Tools.MyAgent.Agent
_failover
.
RecordSuccess
();
if
(
wasOnBackup
)
{
AgentTelemetry
.
Count
(
"agent_failover_total|event=restore"
);
OnTextDelta
?.
Invoke
(
"\n\n(主模型已恢复,切回主模型继续回答)"
);
}
return
r
;
...
...
@@ -702,6 +741,8 @@ namespace Tools.MyAgent.Agent
{
if
(
_failover
.
RecordFailure
(
ex
))
{
if
(
_runScope
!=
null
)
_runScope
.
FailoverCount
++;
AgentTelemetry
.
Count
(
"agent_failover_total|event=switch"
);
OnTextDelta
?.
Invoke
(
"\n\n(主模型服务异常,已自动切换至备用模型继续回答)"
);
AgentLogger
.
Log
(
"FAILOVER 切换至备用模型:"
+
_failover
.
CurrentClient
.
Model
);
_lastServedModel
=
_failover
.
CurrentClient
.
Model
;
// R6:切换后按备用模型价格计费
...
...
@@ -1130,6 +1171,15 @@ namespace Tools.MyAgent.Agent
if
(
done
!=
execTask
)
{
AgentLogger
.
Log
(
$"工具
{
tc
.
Name
}
未在协作取消后按时结束,已放弃等待"
);
AgentTelemetry
.
RecordTool
(
_runScope
,
new
AgentTelemetry
.
ToolSpan
{
RunId
=
_diagnosticSessionId
,
Name
=
tc
.
Name
,
ArgsSummary
=
AgentTelemetry
.
Summarize
(
tc
.
Arguments
),
DurationMs
=
ToolTimeoutSeconds
*
1000L
,
Outcome
=
ct
.
IsCancellationRequested
?
"cancel"
:
"timeout"
,
ErrorType
=
"timeout"
,
});
return
ct
.
IsCancellationRequested
?
"已取消。"
:
$"错误:工具
{
tc
.
Name
}
执行超时(
{
ToolTimeoutSeconds
}
秒),已中止。"
;
...
...
@@ -1138,7 +1188,17 @@ namespace Tools.MyAgent.Agent
}
catch
(
OperationCanceledException
)
{
if
(
ct
.
IsCancellationRequested
)
bool
userCanceled
=
ct
.
IsCancellationRequested
;
AgentTelemetry
.
RecordTool
(
_runScope
,
new
AgentTelemetry
.
ToolSpan
{
RunId
=
_diagnosticSessionId
,
Name
=
tc
.
Name
,
ArgsSummary
=
AgentTelemetry
.
Summarize
(
tc
.
Arguments
),
DurationMs
=
ToolTimeoutSeconds
*
1000L
,
Outcome
=
userCanceled
?
"cancel"
:
"timeout"
,
ErrorType
=
userCanceled
?
""
:
"timeout"
,
});
if
(
userCanceled
)
return
"已取消。"
;
return
$"错误:工具
{
tc
.
Name
}
执行超时(
{
ToolTimeoutSeconds
}
秒),已中止。"
;
}
...
...
@@ -1203,6 +1263,17 @@ namespace Tools.MyAgent.Agent
sw
.
Stop
();
// #29 工具日志
AgentLogger
.
LogTool
(
tc
.
Name
,
desc
,
sw
.
ElapsedMilliseconds
,
SummarizeToolResult
(
result
),
_diagnosticSessionId
);
AgentTelemetry
.
RecordTool
(
_runScope
,
new
AgentTelemetry
.
ToolSpan
{
RunId
=
_diagnosticSessionId
,
Name
=
tc
.
Name
,
ArgsSummary
=
AgentTelemetry
.
Summarize
(
desc
),
StartOffsetMs
=
_runScope
!=
null
&&
_runScope
.
Sw
!=
null
?
_runScope
.
Sw
.
ElapsedMilliseconds
-
sw
.
ElapsedMilliseconds
:
0
,
DurationMs
=
sw
.
ElapsedMilliseconds
,
Outcome
=
"ok"
,
ResultChars
=
(
result
??
""
).
Length
,
Truncated
=
result
!=
null
&&
result
.
Length
>
MaxToolReturnChars
,
});
// #42 结果压缩:超长结果压缩后回填模型,完整结果落盘供溯源
result
=
CompressToolResult
(
result
,
tc
.
Name
)
??
""
;
if
(
gen
==
System
.
Threading
.
Volatile
.
Read
(
ref
_runGeneration
))
...
...
@@ -1228,6 +1299,15 @@ namespace Tools.MyAgent.Agent
catch
(
Exception
ex
)
{
sw
.
Stop
();
AgentTelemetry
.
RecordTool
(
_runScope
,
new
AgentTelemetry
.
ToolSpan
{
RunId
=
_diagnosticSessionId
,
Name
=
tc
.
Name
,
ArgsSummary
=
AgentTelemetry
.
Summarize
(
tc
.
Arguments
),
DurationMs
=
sw
.
ElapsedMilliseconds
,
Outcome
=
"error"
,
ErrorType
=
"unknown"
,
});
if
(
gen
==
System
.
Threading
.
Volatile
.
Read
(
ref
_runGeneration
))
{
lock
(
_toolTrace
)
...
...
@@ -1235,7 +1315,8 @@ namespace Tools.MyAgent.Agent
_toolTrace
.
Add
(
new
ToolTraceEntry
{
Name
=
tc
.
Name
,
Args
=
tc
.
Name
,
// 修复:此前异常分支写的是工具名,与正常分支的参数摘要不一致
Args
=
AgentTelemetry
.
Summarize
(
tc
.
Arguments
),
DurationMs
=
sw
.
ElapsedMilliseconds
,
ResultSummary
=
"异常:"
+
ex
.
Message
,
});
...
...
@@ -1283,6 +1364,19 @@ namespace Tools.MyAgent.Agent
#
endregion
/// <summary>把异常归类为可聚合的 ErrorType(观测用,不参与用户提示文案)。</summary>
private
static
string
ClassifyError
(
Exception
ex
)
{
string
msg
=
(
ex
?.
InnerException
?.
Message
??
ex
?.
Message
??
""
).
ToLowerInvariant
();
if
(
msg
.
Contains
(
"401"
))
return
"auth"
;
if
(
msg
.
Contains
(
"402"
))
return
"quota"
;
if
(
msg
.
Contains
(
"429"
))
return
"ratelimit"
;
if
(
msg
.
Contains
(
"413"
)
||
msg
.
Contains
(
"context_length"
)
||
msg
.
Contains
(
"上下文"
))
return
"context_length"
;
if
(
msg
.
Contains
(
"timeout"
)
||
msg
.
Contains
(
"超时"
)
||
msg
.
Contains
(
"timed out"
))
return
"timeout"
;
if
(
msg
.
Contains
(
"connection"
)
||
msg
.
Contains
(
"连接"
)
||
msg
.
Contains
(
"5"
))
return
"network"
;
return
"unknown"
;
}
private
static
string
DescribeError
(
Exception
ex
)
{
// 鉴权 / 额度错误给出中文提示
...
...
Tools.Agent/Agent/OpenAiCompatibleClient.cs
View file @
5b9144c4
...
...
@@ -2,6 +2,7 @@
using
Newtonsoft.Json.Linq
;
using
System
;
using
System.Collections.Generic
;
using
System.Diagnostics
;
using
System.IO
;
using
System.Linq
;
using
System.Net
;
...
...
@@ -30,6 +31,9 @@ namespace Tools.MyAgent.Agent
public
string
FinishReason
{
get
;
set
;
}
=
""
;
/// <summary>用量(SSE 末帧 usage;无则 -1)</summary>
public
int
PromptTokens
{
get
;
set
;
}
=
-
1
;
/// <summary>首包延迟(ms):发出请求到收到首个有效 delta;无数据为 0(M6,向后兼容)</summary>
public
long
TtfbMs
{
get
;
set
;
}
public
int
CompletionTokens
{
get
;
set
;
}
=
-
1
;
}
...
...
@@ -542,6 +546,8 @@ namespace Tools.MyAgent.Agent
private
async
Task
<
ChatResult
>
ReadSseAsync
(
StreamReader
reader
,
CancellationToken
ct
)
{
var
result
=
new
ChatResult
();
var
ttfbSw
=
Stopwatch
.
StartNew
();
bool
ttfbSet
=
false
;
var
contentBuffer
=
new
StringBuilder
();
var
reasoningBuffer
=
new
StringBuilder
();
var
toolCalls
=
new
Dictionary
<
int
,
ApiToolCall
>();
...
...
@@ -582,6 +588,13 @@ namespace Tools.MyAgent.Agent
var
delta
=
first
[
"delta"
]
as
JObject
;
if
(
delta
==
null
)
continue
;
// M6:首包延迟打点(首个有效 delta,正文/思维链任一均可)
if
(!
ttfbSet
&&
(
delta
[
"content"
]
!=
null
||
delta
[
"reasoning_content"
]
!=
null
))
{
ttfbSet
=
true
;
result
.
TtfbMs
=
ttfbSw
.
ElapsedMilliseconds
;
}
// deepseek-reasoner 思维链
var
reasoning
=
delta
[
"reasoning_content"
]?.
Value
<
string
>();
if
(!
string
.
IsNullOrEmpty
(
reasoning
))
...
...
Tools.Agent/AgentRuntime.cs
View file @
5b9144c4
using
System
;
using
System.Windows.Forms
;
using
System.Collections.Generic
;
using
System.IO
;
using
System.Linq
;
...
...
@@ -99,6 +100,12 @@ namespace Tools.MyAgent
return
new
AgentEngine
(
knowledgeBase
,
DataRoot
,
ProductContext
,
_diagnosticRoots
)
{
CurrentUser
=
_options
.
CurrentUser
??
"访客"
};
}
/// <summary>创建 Admin 看板窗体(供宿主挂到菜单/工具栏)。</summary>
public
Form
CreateAdminForm
(
int
days
=
7
,
bool
allowDetailed
=
false
)
{
return
new
Tools
.
MyAgent
.
Forms
.
AgentAdminForm
(
null
,
days
,
allowDetailed
);
}
private
void
EnsureDefaultResources
()
{
Directory
.
CreateDirectory
(
DataRoot
);
...
...
Tools.Agent/Config/AgentConfig.cs
View file @
5b9144c4
...
...
@@ -174,5 +174,36 @@
/// <para>空字典/缺省 = 所有登录用户可用(保持现状);值为空串或 "*" = 不限制。</para>
/// </summary>
public
Dictionary
<
string
,
string
>
ToolRoleAllow
{
get
;
set
;
}
=
new
Dictionary
<
string
,
string
>();
// ══════════════ 高级项:可观测性(不进设置页,内部默认 + 手改配置文件微调) ══════════════
/// <summary>可观测性总开关;关闭则不写 jsonl、不建队列(纯文本日志照旧)。</summary>
public
bool
TelemetryEnabled
{
get
;
set
;
}
=
true
;
/// <summary>隐私级别:<c>off</c> 不采集 / <c>basic</c> 结构化字段不写原文(默认) / <c>detailed</c> 追加提问与工具返回摘要。</summary>
public
string
ObservabilityLevel
{
get
;
set
;
}
=
"basic"
;
/// <summary>启动时执行一次依赖自检(默认关闭,仅 /diag 与看板触发)。</summary>
public
bool
HealthCheckOnStart
{
get
;
set
;
}
=
false
;
/// <summary>runs_*.jsonl 保留天数(默认 14)。</summary>
public
int
RunsKeepDays
{
get
;
set
;
}
=
14
;
/// <summary>回复尾注是否带本轮摘要(耗时/工具数/通道);默认关闭,避免打扰。</summary>
public
bool
ShowRunSummary
{
get
;
set
;
}
=
false
;
// ── Admin 看板 ──
/// <summary>看板总开关;关闭时 /admin 提示不可用。</summary>
public
bool
AdminEnabled
{
get
;
set
;
}
=
true
;
/// <summary>允许打开看板的角色(逗号分隔);空 = 不限制(保持现状,不破坏既有宿主)。</summary>
public
string
AdminRoles
{
get
;
set
;
}
=
""
;
/// <summary>看板读取最近 N 天(1~30)。</summary>
public
int
DashboardDays
{
get
;
set
;
}
=
7
;
/// <summary>看板 30s 自动刷新(默认关闭,避免常驻定时器拖住宿主)。</summary>
public
bool
AdminAutoRefresh
{
get
;
set
;
}
=
false
;
}
}
Tools.Agent/Forms/AgentAdminForm.cs
0 → 100644
View file @
5b9144c4
using
Newtonsoft.Json.Linq
;
using
System
;
using
System.Drawing
;
using
System.IO
;
using
System.Text
;
using
System.Threading.Tasks
;
using
System.Windows.Forms
;
using
Tools.MyAgent
;
using
Tools.MyAgent.Support
;
// BackupHelper(命名空间为 Tools.MyAgent.Support)
namespace
Tools.MyAgent.Forms
{
/// <summary>
/// Admin 看板(管理面板):一屏看回答质量、成本、稳定性与知识缺口。
/// <para>纯代码构建 UI(不配 Designer/resx)——本机无 VS 设计器,手写 Designer 易漏 InitializeComponent。</para>
/// <para>与聊天窗 <c>/stats</c> <c>/diag</c> 互补:那两条是终端用户随口问,这里是管理者看趋势。</para>
/// </summary>
public
sealed
class
AgentAdminForm
:
Form
{
private
readonly
TelemetryStore
_store
;
private
readonly
bool
_allowDetailed
;
private
TelemetryStore
.
RunRow
[]
_runs
=
new
TelemetryStore
.
RunRow
[
0
];
private
readonly
TabControl
_tabs
=
new
TabControl
();
private
readonly
Label
_kpi
=
new
Label
();
private
readonly
DashChart
_hourly
=
new
DashChart
();
private
readonly
DashChart
_toolChart
=
new
DashChart
();
private
readonly
DataGridView
_gridTime
=
NewGrid
();
private
readonly
DataGridView
_gridTools
=
NewGrid
();
private
readonly
DataGridView
_gridKb
=
NewGrid
();
private
readonly
DataGridView
_gridErr
=
NewGrid
();
private
readonly
DataGridView
_gridHealth
=
NewGrid
();
private
readonly
TextBox
_detail
=
new
TextBox
();
private
readonly
Timer
_timer
=
new
Timer
();
private
readonly
Label
_hint
=
new
Label
();
public
AgentAdminForm
(
string
logsRoot
=
null
,
int
days
=
7
,
bool
allowDetailed
=
false
)
{
_allowDetailed
=
allowDetailed
;
_store
=
new
TelemetryStore
(
logsRoot
,
days
)
{
AllowDetailed
=
allowDetailed
};
Text
=
"智能体运行看板"
;
StartPosition
=
FormStartPosition
.
CenterScreen
;
Size
=
new
Size
(
1000
,
680
);
MinimumSize
=
new
Size
(
880
,
560
);
_tabs
.
Dock
=
DockStyle
.
Fill
;
_tabs
.
TabPages
.
Add
(
OverviewPage
());
_tabs
.
TabPages
.
Add
(
TimelinePage
());
_tabs
.
TabPages
.
Add
(
ToolsPage
());
_tabs
.
TabPages
.
Add
(
KbPage
());
_tabs
.
TabPages
.
Add
(
ErrorPage
());
var
top
=
new
Panel
{
Dock
=
DockStyle
.
Top
,
Height
=
36
,
Padding
=
new
Padding
(
6
,
4
,
6
,
0
)
};
var
btnRefresh
=
new
Button
{
Text
=
"刷新"
,
Width
=
70
,
Height
=
26
,
Left
=
6
,
Top
=
4
};
var
btnCsv
=
new
Button
{
Text
=
"导出 CSV"
,
Width
=
90
,
Height
=
26
,
Left
=
84
,
Top
=
4
};
var
btnMd
=
new
Button
{
Text
=
"导出 Markdown"
,
Width
=
120
,
Height
=
26
,
Left
=
180
,
Top
=
4
};
var
btnHtml
=
new
Button
{
Text
=
"导出 HTML"
,
Width
=
90
,
Height
=
26
,
Left
=
306
,
Top
=
4
};
var
btnPack
=
new
Button
{
Text
=
"诊断包"
,
Width
=
80
,
Height
=
26
,
Left
=
402
,
Top
=
4
};
var
chkAuto
=
new
CheckBox
{
Text
=
"30s 自动刷新"
,
Left
=
490
,
Top
=
7
,
Width
=
120
};
_hint
.
Left
=
620
;
_hint
.
Top
=
9
;
_hint
.
Width
=
360
;
_hint
.
Height
=
22
;
_hint
.
ForeColor
=
Color
.
FromArgb
(
120
,
120
,
120
);
_hint
.
Text
=
allowDetailed
?
"隐私级别:detailed"
:
"隐私级别:basic(参数摘要已隐藏)"
;
top
.
Controls
.
AddRange
(
new
Control
[]
{
btnRefresh
,
btnCsv
,
btnMd
,
btnHtml
,
btnPack
,
chkAuto
,
_hint
});
btnRefresh
.
Click
+=
(
s
,
e
)
=>
LoadData
();
btnCsv
.
Click
+=
(
s
,
e
)
=>
ExportCsv
();
btnMd
.
Click
+=
(
s
,
e
)
=>
ExportMarkdown
();
btnHtml
.
Click
+=
(
s
,
e
)
=>
ExportHtml
();
btnPack
.
Click
+=
(
s
,
e
)
=>
ExportPackage
();
chkAuto
.
CheckedChanged
+=
(
s
,
e
)
=>
{
_timer
.
Enabled
=
chkAuto
.
Checked
;
};
_timer
.
Interval
=
30000
;
_timer
.
Tick
+=
(
s
,
e
)
=>
LoadData
();
Controls
.
Add
(
_tabs
);
Controls
.
Add
(
top
);
Shown
+=
async
(
s
,
e
)
=>
await
LoadAsync
();
}
// ══════════════ 页面构建 ══════════════
private
TabPage
OverviewPage
()
{
var
p
=
new
TabPage
(
"① 概览"
);
_kpi
.
Dock
=
DockStyle
.
Top
;
_kpi
.
Height
=
92
;
_kpi
.
Padding
=
new
Padding
(
8
);
_kpi
.
Font
=
new
Font
(
FontFamily
.
GenericSansSerif
,
10f
);
_kpi
.
Text
=
"加载中…"
;
_hourly
.
Dock
=
DockStyle
.
Fill
;
p
.
Controls
.
Add
(
_hourly
);
p
.
Controls
.
Add
(
_kpi
);
return
p
;
}
private
TabPage
TimelinePage
()
{
var
p
=
new
TabPage
(
"② 时间线"
);
_gridTime
.
Dock
=
DockStyle
.
Fill
;
_gridTime
.
SelectionChanged
+=
(
s
,
e
)
=>
ShowRunDetail
();
_detail
.
Dock
=
DockStyle
.
Bottom
;
_detail
.
Height
=
150
;
_detail
.
Multiline
=
true
;
_detail
.
ScrollBars
=
ScrollBars
.
Vertical
;
_detail
.
ReadOnly
=
true
;
_detail
.
BackColor
=
Color
.
White
;
p
.
Controls
.
Add
(
_gridTime
);
p
.
Controls
.
Add
(
_detail
);
return
p
;
}
private
TabPage
ToolsPage
()
{
var
p
=
new
TabPage
(
"③ 工具"
);
var
split
=
new
SplitContainer
{
Dock
=
DockStyle
.
Fill
,
Orientation
=
Orientation
.
Horizontal
};
_toolChart
.
Dock
=
DockStyle
.
Fill
;
_toolChart
.
Horizontal
=
true
;
_gridTools
.
Dock
=
DockStyle
.
Fill
;
split
.
Panel1
.
Controls
.
Add
(
_toolChart
);
split
.
Panel2
.
Controls
.
Add
(
_gridTools
);
split
.
SplitterDistance
=
220
;
p
.
Controls
.
Add
(
split
);
return
p
;
}
private
TabPage
KbPage
()
{
var
p
=
new
TabPage
(
"④ 知识库"
);
_gridKb
.
Dock
=
DockStyle
.
Fill
;
p
.
Controls
.
Add
(
_gridKb
);
return
p
;
}
private
TabPage
ErrorPage
()
{
var
p
=
new
TabPage
(
"⑤ 错误与健康"
);
var
split
=
new
SplitContainer
{
Dock
=
DockStyle
.
Fill
,
Orientation
=
Orientation
.
Horizontal
};
var
left
=
new
SplitContainer
{
Dock
=
DockStyle
.
Fill
,
Orientation
=
Orientation
.
Vertical
};
_gridErr
.
Dock
=
DockStyle
.
Fill
;
_gridHealth
.
Dock
=
DockStyle
.
Fill
;
left
.
Panel1
.
Controls
.
Add
(
_gridErr
);
left
.
Panel2
.
Controls
.
Add
(
_gridHealth
);
split
.
Panel1
.
Controls
.
Add
(
left
);
var
lab
=
new
Label
{
Dock
=
DockStyle
.
Fill
,
Padding
=
new
Padding
(
8
),
Text
=
"健康检查数据来自 Logs/health.json —— 在聊天窗执行 /diag 可生成/刷新。"
,
};
split
.
Panel2
.
Controls
.
Add
(
lab
);
split
.
SplitterDistance
=
340
;
p
.
Controls
.
Add
(
split
);
return
p
;
}
private
static
DataGridView
NewGrid
()
{
var
g
=
new
DataGridView
{
ReadOnly
=
true
,
AllowUserToAddRows
=
false
,
AllowUserToDeleteRows
=
false
,
SelectionMode
=
DataGridViewSelectionMode
.
FullRowSelect
,
AutoSizeColumnsMode
=
DataGridViewAutoSizeColumnsMode
.
Fill
,
BackgroundColor
=
Color
.
White
,
RowHeadersVisible
=
false
,
};
return
g
;
}
// ══════════════ 数据加载 ══════════════
private
async
Task
LoadAsync
()
{
try
{
_runs
=
await
Task
.
Run
(()
=>
_store
.
LoadRuns
());
Bind
();
}
catch
{
}
}
private
void
LoadData
()
{
try
{
_runs
=
_store
.
LoadRuns
();
Bind
();
}
catch
{
}
}
private
void
Bind
()
{
try
{
BindOverview
();
BindTimeline
();
BindTools
();
BindKb
();
BindErrors
();
BindHealth
();
if
(
_store
.
ReadLines
>
0
&&
_store
.
BadLines
*
20
>
_store
.
ReadLines
)
_hint
.
Text
=
"⚠ 日志文件疑似损坏(坏行 "
+
_store
.
BadLines
+
"/"
+
_store
.
ReadLines
+
")"
;
}
catch
{
}
}
private
void
BindOverview
()
{
int
runs
=
_runs
.
Length
;
int
ok
=
0
,
err
=
0
,
cancel
=
0
;
long
tokens
=
0
;
double
cost
=
0
;
int
tools
=
0
,
llms
=
0
;
foreach
(
var
r
in
_runs
)
{
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
(
"轮数 {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
[]
okHours
=
_store
.
Hourly
(
_runs
,
out
failed
);
var
labels
=
new
string
[
24
];
var
v1
=
new
double
[
24
];
var
v2
=
new
double
[
24
];
for
(
int
i
=
0
;
i
<
24
;
i
++)
{
labels
[
i
]
=
i
.
ToString
(
"00"
);
v1
[
i
]
=
okHours
[
i
];
v2
[
i
]
=
failed
[
i
];
}
_hourly
.
Labels
=
labels
;
_hourly
.
Values
=
v1
;
_hourly
.
Values2
=
v2
;
_hourly
.
EmptyText
=
"近 24 小时无数据"
;
_hourly
.
Caption
=
"近 24 小时 · 按启动小时(绿=成功 红=失败,柱顶数字=该小时轮数)"
;
_hourly
.
Invalidate
();
}
private
void
BindTimeline
()
{
_gridTime
.
Columns
.
Clear
();
_gridTime
.
Rows
.
Clear
();
AddCols
(
_gridTime
,
"时间"
,
"通道"
,
"结果"
,
"耗时ms"
,
"工具"
,
"LLM"
,
"Token"
,
"费用"
,
"模型"
,
"RunId"
);
int
n
=
Math
.
Min
(
200
,
_runs
.
Length
);
for
(
int
i
=
0
;
i
<
n
;
i
++)
{
var
r
=
_runs
[
i
];
_gridTime
.
Rows
.
Add
(
r
.
StartedAt
==
DateTime
.
MinValue
?
"-"
:
r
.
StartedAt
.
ToString
(
"MM-dd HH:mm:ss"
),
r
.
Mode
,
r
.
Outcome
,
r
.
TotalMs
,
r
.
ToolCalls
,
r
.
LlmCalls
,
r
.
PromptTokens
+
r
.
CompletionTokens
,
r
.
Cost
>
0
?
CostEstimator
.
Format
(
r
.
Cost
)
:
"—"
,
r
.
Model
,
r
.
RunId
);
}
}
private
void
ShowRunDetail
()
{
try
{
if
(
_gridTime
.
SelectedRows
.
Count
==
0
)
{
_detail
.
Text
=
""
;
return
;
}
string
runId
=
Convert
.
ToString
(
_gridTime
.
SelectedRows
[
0
].
Cells
[
"RunId"
].
Value
);
foreach
(
var
r
in
_runs
)
{
if
(!
string
.
Equals
(
r
.
RunId
,
runId
,
StringComparison
.
Ordinal
))
continue
;
var
sb
=
new
StringBuilder
();
sb
.
AppendLine
(
"RunId "
+
r
.
RunId
+
" · "
+
r
.
Mode
+
" · "
+
r
.
Outcome
+
(
string
.
IsNullOrEmpty
(
r
.
ErrorType
)
?
""
:
" / "
+
r
.
ErrorType
));
sb
.
AppendLine
(
"耗时 "
+
r
.
TotalMs
+
"ms · LLM "
+
r
.
LlmCalls
+
" 次 · token "
+
r
.
PromptTokens
+
"+"
+
r
.
CompletionTokens
);
sb
.
AppendLine
(
"— 工具时间线 —"
);
if
(
r
.
Tools
.
Count
==
0
)
sb
.
AppendLine
(
"(本轮未调用工具)"
);
foreach
(
var
t
in
r
.
Tools
)
sb
.
AppendLine
(
string
.
Format
(
" {0} {1}ms {2}{3}{4}"
,
t
.
Name
,
t
.
DurationMs
,
t
.
Outcome
,
t
.
Truncated
?
" [结果截断]"
:
""
,
_allowDetailed
&&
t
.
ArgsSummary
.
Length
>
0
?
" "
+
t
.
ArgsSummary
:
""
));
_detail
.
Text
=
sb
.
ToString
();
return
;
}
}
catch
{
}
}
private
void
BindTools
()
{
var
aggs
=
_store
.
AggregateTools
(
_runs
);
_gridTools
.
Columns
.
Clear
();
_gridTools
.
Rows
.
Clear
();
AddCols
(
_gridTools
,
"工具"
,
"调用"
,
"错误"
,
"超时"
,
"截断"
,
"失败率%"
,
"P50ms"
,
"P95ms"
,
"最大ms"
);
var
names
=
new
string
[
aggs
.
Length
];
var
vals
=
new
double
[
aggs
.
Length
];
for
(
int
i
=
0
;
i
<
aggs
.
Length
;
i
++)
{
var
a
=
aggs
[
i
];
_gridTools
.
Rows
.
Add
(
a
.
Name
,
a
.
Calls
,
a
.
Errors
,
a
.
Timeouts
,
a
.
Truncated
,
a
.
FailRate
.
ToString
(
"F1"
),
a
.
P50
,
a
.
P95
,
a
.
Max
);
names
[
i
]
=
a
.
Name
;
vals
[
i
]
=
a
.
P95
;
}
_toolChart
.
Labels
=
names
;
_toolChart
.
Values
=
vals
;
_toolChart
.
Values2
=
new
double
[
0
];
_toolChart
.
EmptyText
=
"暂无工具调用"
;
_toolChart
.
Invalidate
();
}
private
void
BindKb
()
{
var
m
=
_store
.
AggregateModes
(
_runs
);
_gridKb
.
Columns
.
Clear
();
_gridKb
.
Rows
.
Clear
();
AddCols
(
_gridKb
,
"指标"
,
"值"
,
"说明"
);
_gridKb
.
Rows
.
Add
(
"直答率"
,
m
.
DirectRate
.
ToString
(
"F1"
)
+
"%"
,
"kb + mixed 占总轮数(P3 本地召回收益)"
);
_gridKb
.
Rows
.
Add
(
"kb 命中直答"
,
m
.
Kb
,
"证据充分,单轮直答"
);
_gridKb
.
Rows
.
Add
(
"mixed 部分命中"
,
m
.
Mixed
,
"证据 + 模型补充"
);
_gridKb
.
Rows
.
Add
(
"model 未召回"
,
m
.
Model
,
"知识库未命中"
);
_gridKb
.
Rows
.
Add
(
"tool-loop 工具循环"
,
m
.
ToolLoop
,
"多轮工具检索"
);
_gridKb
.
Rows
.
Add
(
"检索零命中"
,
AgentTelemetry
.
Get
(
"agent_kb_zero_hit_total"
),
"进程内累计(知识缺口信号)"
);
}
private
void
BindErrors
()
{
var
errs
=
_store
.
AggregateErrors
(
_runs
,
20
);
_gridErr
.
Columns
.
Clear
();
_gridErr
.
Rows
.
Clear
();
AddCols
(
_gridErr
,
"错误类型"
,
"次数"
);
foreach
(
var
kv
in
errs
)
_gridErr
.
Rows
.
Add
(
kv
.
Key
,
kv
.
Value
);
var
recent
=
_store
.
RecentErrors
(
_runs
,
20
);
foreach
(
var
r
in
recent
)
_gridErr
.
Rows
.
Add
(
"最近 · "
+
(
r
.
StartedAt
==
DateTime
.
MinValue
?
"-"
:
r
.
StartedAt
.
ToString
(
"MM-dd HH:mm"
))
+
" · "
+
r
.
ErrorType
,
r
.
Outcome
);
}
private
void
BindHealth
()
{
_gridHealth
.
Columns
.
Clear
();
_gridHealth
.
Rows
.
Clear
();
AddCols
(
_gridHealth
,
"状态"
,
"检查项"
,
"说明"
);
try
{
string
path
=
Path
.
Combine
(
_store
.
LogsRoot
,
"health.json"
);
if
(!
File
.
Exists
(
path
))
{
_gridHealth
.
Rows
.
Add
(
"—"
,
"未生成"
,
"在聊天窗执行 /diag 后刷新"
);
return
;
}
var
o
=
JObject
.
Parse
(
File
.
ReadAllText
(
path
,
Encoding
.
UTF8
));
var
arr
=
o
[
"items"
]
as
JArray
;
if
(
arr
==
null
)
return
;
foreach
(
var
it
in
arr
)
{
var
jo
=
it
as
JObject
;
if
(
jo
==
null
)
continue
;
string
st
=
Str
(
jo
,
"Status"
);
_gridHealth
.
Rows
.
Add
(
st
==
"fail"
?
"❌"
:
st
==
"warn"
?
"⚠️"
:
"✅"
,
Str
(
jo
,
"Name"
),
Str
(
jo
,
"Detail"
));
}
}
catch
{
}
}
private
static
void
AddCols
(
DataGridView
g
,
params
string
[]
names
)
{
foreach
(
var
n
in
names
)
g
.
Columns
.
Add
(
n
.
Replace
(
" "
,
""
),
n
);
}
private
static
string
Str
(
JObject
o
,
string
name
)
{
var
v
=
o
[
name
];
return
v
==
null
?
""
:
v
.
ToString
();
}
// ══════════════ 导出 ══════════════
private
DataGridView
CurrentGrid
()
{
switch
(
_tabs
.
SelectedIndex
)
{
case
1
:
return
_gridTime
;
case
2
:
return
_gridTools
;
case
3
:
return
_gridKb
;
case
4
:
return
_gridErr
;
default
:
return
_gridTime
;
}
}
private
void
ExportCsv
()
{
try
{
var
dlg
=
new
SaveFileDialog
{
Filter
=
"CSV 文件|*.csv"
,
FileName
=
"agent_stats.csv"
};
if
(
dlg
.
ShowDialog
(
this
)
!=
DialogResult
.
OK
)
return
;
var
g
=
CurrentGrid
();
var
sb
=
new
StringBuilder
();
for
(
int
c
=
0
;
c
<
g
.
Columns
.
Count
;
c
++)
sb
.
Append
((
c
>
0
?
","
:
""
)
+
"\""
+
g
.
Columns
[
c
].
HeaderText
+
"\""
);
sb
.
Append
(
"\n"
);
foreach
(
DataGridViewRow
row
in
g
.
Rows
)
{
for
(
int
c
=
0
;
c
<
g
.
Columns
.
Count
;
c
++)
{
string
v
=
row
.
Cells
[
c
].
Value
==
null
?
""
:
row
.
Cells
[
c
].
Value
.
ToString
();
sb
.
Append
((
c
>
0
?
","
:
""
)
+
"\""
+
v
.
Replace
(
"\""
,
"'"
)
+
"\""
);
}
sb
.
Append
(
"\n"
);
}
File
.
WriteAllText
(
dlg
.
FileName
,
sb
.
ToString
(),
new
UTF8Encoding
(
true
));
// BOM:Excel 中文不乱码
MessageBox
.
Show
(
this
,
"已导出:"
+
dlg
.
FileName
,
"导出 CSV"
,
MessageBoxButtons
.
OK
,
MessageBoxIcon
.
Information
);
}
catch
(
Exception
ex
)
{
MessageBox
.
Show
(
this
,
ex
.
Message
,
"导出失败"
,
MessageBoxButtons
.
OK
,
MessageBoxIcon
.
Error
);
}
}
private
void
ExportMarkdown
()
{
try
{
var
dlg
=
new
SaveFileDialog
{
Filter
=
"Markdown|*.md"
,
FileName
=
"agent_report.md"
};
if
(
dlg
.
ShowDialog
(
this
)
!=
DialogResult
.
OK
)
return
;
var
sb
=
new
StringBuilder
();
sb
.
Append
(
"# 智能体运行概览\n\n生成时间:"
).
Append
(
DateTime
.
Now
.
ToString
(
"yyyy-MM-dd HH:mm"
)).
Append
(
"\n\n"
);
sb
.
Append
(
"```\n"
).
Append
(
_kpi
.
Text
).
Append
(
"\n```\n\n"
);
sb
.
Append
(
"## 工具耗时(P95)\n\n| 工具 | 调用 | 错误 | 超时 | P50ms | P95ms |\n|---|---|---|---|---|---|\n"
);
foreach
(
var
a
in
_store
.
AggregateTools
(
_runs
))
sb
.
Append
(
"| "
).
Append
(
a
.
Name
).
Append
(
" | "
).
Append
(
a
.
Calls
).
Append
(
" | "
).
Append
(
a
.
Errors
)
.
Append
(
" | "
).
Append
(
a
.
Timeouts
).
Append
(
" | "
).
Append
(
a
.
P50
).
Append
(
" | "
).
Append
(
a
.
P95
).
Append
(
" |\n"
);
sb
.
Append
(
"\n## 错误分布\n\n| 类型 | 次数 |\n|---|---|\n"
);
foreach
(
var
kv
in
_store
.
AggregateErrors
(
_runs
,
20
))
sb
.
Append
(
"| "
).
Append
(
kv
.
Key
).
Append
(
" | "
).
Append
(
kv
.
Value
).
Append
(
" |\n"
);
File
.
WriteAllText
(
dlg
.
FileName
,
sb
.
ToString
(),
new
UTF8Encoding
(
false
));
MessageBox
.
Show
(
this
,
"已导出:"
+
dlg
.
FileName
,
"导出 Markdown"
,
MessageBoxButtons
.
OK
,
MessageBoxIcon
.
Information
);
}
catch
(
Exception
ex
)
{
MessageBox
.
Show
(
this
,
ex
.
Message
,
"导出失败"
,
MessageBoxButtons
.
OK
,
MessageBoxIcon
.
Error
);
}
}
private
void
ExportHtml
()
{
try
{
var
dlg
=
new
SaveFileDialog
{
Filter
=
"HTML|*.html"
,
FileName
=
"agent_report.html"
};
if
(
dlg
.
ShowDialog
(
this
)
!=
DialogResult
.
OK
)
return
;
var
sb
=
new
StringBuilder
();
sb
.
Append
(
"<!DOCTYPE html><html><head><meta charset=\"utf-8\"><title>智能体运行概览</title>"
)
.
Append
(
"<style>body{font-family:'Microsoft YaHei',sans-serif;margin:24px;color:#222}"
)
.
Append
(
"table{border-collapse:collapse;width:100%}th,td{border:1px solid #ddd;padding:6px 8px;text-align:left}"
)
.
Append
(
"th{background:#f4f6f8}pre{background:#f6f8fa;padding:12px}</style></head><body>"
)
.
Append
(
"<h1>智能体运行概览</h1><p>"
).
Append
(
DateTime
.
Now
.
ToString
(
"yyyy-MM-dd HH:mm"
)).
Append
(
"</p><pre>"
)
.
Append
(
_kpi
.
Text
).
Append
(
"</pre>"
);
var
g
=
CurrentGrid
();
sb
.
Append
(
"<h2>明细</h2><table><tr>"
);
for
(
int
c
=
0
;
c
<
g
.
Columns
.
Count
;
c
++)
sb
.
Append
(
"<th>"
).
Append
(
g
.
Columns
[
c
].
HeaderText
).
Append
(
"</th>"
);
sb
.
Append
(
"</tr>"
);
foreach
(
DataGridViewRow
row
in
g
.
Rows
)
{
sb
.
Append
(
"<tr>"
);
for
(
int
c
=
0
;
c
<
g
.
Columns
.
Count
;
c
++)
sb
.
Append
(
"<td>"
).
Append
(
row
.
Cells
[
c
].
Value
==
null
?
""
:
row
.
Cells
[
c
].
Value
.
ToString
()).
Append
(
"</td>"
);
sb
.
Append
(
"</tr>"
);
}
sb
.
Append
(
"</table></body></html>"
);
File
.
WriteAllText
(
dlg
.
FileName
,
sb
.
ToString
(),
new
UTF8Encoding
(
false
));
MessageBox
.
Show
(
this
,
"已导出:"
+
dlg
.
FileName
,
"导出 HTML"
,
MessageBoxButtons
.
OK
,
MessageBoxIcon
.
Information
);
}
catch
(
Exception
ex
)
{
MessageBox
.
Show
(
this
,
ex
.
Message
,
"导出失败"
,
MessageBoxButtons
.
OK
,
MessageBoxIcon
.
Error
);
}
}
private
void
ExportPackage
()
{
try
{
var
dlg
=
new
SaveFileDialog
{
Filter
=
"ZIP|*.zip"
,
FileName
=
"agent_diag.zip"
};
if
(
dlg
.
ShowDialog
(
this
)
!=
DialogResult
.
OK
)
return
;
string
agentRoot
=
Path
.
Combine
(
AppDomain
.
CurrentDomain
.
BaseDirectory
,
"Agent"
);
string
path
=
BackupHelper
.
BackupAgent
(
agentRoot
,
null
,
dlg
.
FileName
);
MessageBox
.
Show
(
this
,
string
.
IsNullOrEmpty
(
path
)
?
"导出失败"
:
"已导出:"
+
path
,
"诊断包"
,
MessageBoxButtons
.
OK
,
MessageBoxIcon
.
Information
);
}
catch
(
Exception
ex
)
{
MessageBox
.
Show
(
this
,
ex
.
Message
,
"导出失败"
,
MessageBoxButtons
.
OK
,
MessageBoxIcon
.
Error
);
}
}
protected
override
void
Dispose
(
bool
disposing
)
{
try
{
if
(
disposing
)
{
_timer
.
Enabled
=
false
;
_timer
.
Dispose
();
}
}
catch
{
}
base
.
Dispose
(
disposing
);
}
}
}
Tools.Agent/Forms/AgentSettingsForm.cs
View file @
5b9144c4
...
...
@@ -120,6 +120,7 @@ namespace Tools.MyAgent.Forms
InitializeComponent
();
BuildDbConnectionsTab
();
// P0:预存数据库连接(db_query 按 ID 引用,聊天不回显密码)
BuildToolPermissionUi
();
// P3:工具级权限(按角色)输入区
BuildDashboardTab
();
// 可观测性:统计看板入口(替代隐晦的 /admin)
// 回答形态不再提供设置页(v1.2):全部内部默认(见 AgentConfig 注释),降低设置页认知负担
BindConfig
();
// 配置值回填控件(控件创建见 Designer.cs)
ApplyProviderMode
();
// 布局联动:BaseUrl 行显隐
...
...
@@ -208,6 +209,125 @@ namespace Tools.MyAgent.Forms
grpOpt
.
Height
=
216
;
}
// ══ 可观测性:统计看板入口(程序化创建,避免侵入设计器;替代隐晦的 /admin) ══
private
TabPage
_dashTab
;
private
Label
_lblDashKpi
;
private
Button
_btnOpenDashboard
;
/// <summary>
/// 新增「统计看板」选项卡:实时进程用量 + 一键打开完整看板(5 Tab)。
/// <para>看板本体仍是独立窗体 AgentAdminForm(1000×680 设计尺寸),此处仅做入口与即时 KPI,避免在窄设置页里硬塞大面板。</para>
/// </summary>
private
void
BuildDashboardTab
()
{
_dashTab
=
new
TabPage
{
Text
=
"统计看板"
,
Padding
=
new
Padding
(
3
)
};
var
grpKpi
=
new
GroupBox
{
Text
=
"运行统计(本进程实时累计)"
,
Location
=
new
Point
(
8
,
8
),
Size
=
new
Size
(
596
,
172
),
};
_lblDashKpi
=
new
Label
{
Location
=
new
Point
(
16
,
26
),
Size
=
new
Size
(
564
,
138
),
AutoSize
=
false
,
Font
=
new
Font
(
"Consolas"
,
9.5F
),
UseMnemonic
=
false
,
};
grpKpi
.
Controls
.
Add
(
_lblDashKpi
);
var
grpOpen
=
new
GroupBox
{
Text
=
"完整看板"
,
Location
=
new
Point
(
8
,
188
),
Size
=
new
Size
(
596
,
116
),
};
_btnOpenDashboard
=
new
Button
{
Text
=
"打开完整看板(多日趋势 · 工具 · 知识库 · 错误与健康)"
,
Location
=
new
Point
(
16
,
26
),
Size
=
new
Size
(
564
,
32
),
};
var
lblOpen
=
new
Label
{
Text
=
"完整看板含 5 个 Tab,可导出 CSV / Markdown / HTML / 诊断包。\n等价命令:在聊天窗输入 /admin。"
,
Location
=
new
Point
(
16
,
64
),
Size
=
new
Size
(
564
,
44
),
AutoSize
=
false
,
ForeColor
=
Color
.
Gray
,
};
grpOpen
.
Controls
.
Add
(
_btnOpenDashboard
);
grpOpen
.
Controls
.
Add
(
lblOpen
);
_dashTab
.
Controls
.
Add
(
grpKpi
);
_dashTab
.
Controls
.
Add
(
grpOpen
);
tabMain
.
TabPages
.
Add
(
_dashTab
);
_btnOpenDashboard
.
Click
+=
(
s
,
e
)
=>
OpenDashboard
();
// 切到该 Tab 时刷新 KPI(设置窗是模态,进来看的就是最新值)
tabMain
.
SelectedIndexChanged
+=
(
s
,
e
)
=>
{
if
(
tabMain
.
SelectedTab
==
_dashTab
)
RefreshDashKpi
();
};
RefreshDashKpi
();
}
/// <summary>打开完整看板(与 /admin 同源,受 AdminEnabled 开关约束)。</summary>
private
void
OpenDashboard
()
{
try
{
if
(!
_config
.
AdminEnabled
)
{
MessageBox
.
Show
(
this
,
"统计看板已关闭(配置项 AdminEnabled=false)。"
,
"提示"
,
MessageBoxButtons
.
OK
,
MessageBoxIcon
.
Information
);
return
;
}
int
days
=
_config
.
DashboardDays
>
0
&&
_config
.
DashboardDays
<=
30
?
_config
.
DashboardDays
:
7
;
// 设置窗本身是模态对话框:用 ShowDialog(this) 保证看板置顶
using
(
var
f
=
new
AgentAdminForm
(
null
,
days
,
Tools
.
MyAgent
.
AgentTelemetry
.
IsDetailed
))
f
.
ShowDialog
(
this
);
}
catch
(
Exception
ex
)
{
MessageBox
.
Show
(
this
,
"打开看板失败:"
+
ex
.
Message
,
"错误"
,
MessageBoxButtons
.
OK
,
MessageBoxIcon
.
Error
);
}
}
/// <summary>即时 KPI(与 /stats 同源:AgentTelemetry.Snapshot)。</summary>
private
void
RefreshDashKpi
()
{
if
(
_lblDashKpi
==
null
)
return
;
try
{
var
st
=
Tools
.
MyAgent
.
AgentTelemetry
.
Snapshot
();
var
sb
=
new
System
.
Text
.
StringBuilder
();
sb
.
Append
(
"本轮进程累计:\n"
);
sb
.
Append
(
string
.
Format
(
" 轮数 {0}(成功 {1} · 失败 {2} · 取消 {3})\n"
,
st
.
Runs
,
st
.
Success
,
st
.
Errors
,
st
.
Cancels
));
if
(
st
.
Duration
.
Count
>
0
)
sb
.
Append
(
string
.
Format
(
" 耗时 P50 {0}ms · P95 {1}ms · 最大 {2}ms\n"
,
st
.
Duration
.
P50
,
st
.
Duration
.
P95
,
st
.
Duration
.
Max
));
if
(
st
.
Ttfb
.
Count
>
0
)
sb
.
Append
(
string
.
Format
(
" 首包 P50 {0}ms · P95 {1}ms\n"
,
st
.
Ttfb
.
P50
,
st
.
Ttfb
.
P95
));
sb
.
Append
(
string
.
Format
(
" 工具 {0} 次 · LLM {1} 次\n"
,
st
.
ToolCalls
,
st
.
LlmCalls
));
long
tokens
=
st
.
PromptTokens
+
st
.
CompletionTokens
;
if
(
tokens
>
0
)
sb
.
Append
(
string
.
Format
(
" Token {0} + {1}\n"
,
st
.
PromptTokens
,
st
.
CompletionTokens
));
if
(
st
.
Cost
>
0
)
sb
.
Append
(
" 费用 "
+
Tools
.
MyAgent
.
CostEstimator
.
Format
(
st
.
Cost
)
+
"\n"
);
if
(
st
.
Dropped
>
0
)
sb
.
Append
(
" ⚠ 丢弃 "
+
st
.
Dropped
+
" 条(采集跟不上)\n"
);
if
(
st
.
Runs
==
0
)
sb
.
Append
(
" (本进程尚未完成任何一轮回答;多日历史见「打开完整看板」)\n"
);
_lblDashKpi
.
Text
=
sb
.
ToString
();
}
catch
{
_lblDashKpi
.
Text
=
"(统计暂不可用)"
;
}
}
/// <summary>角色规则字典 → 编辑文本(排序保证稳定回显)</summary>
private
static
string
[]
ToolRoleRulesToLines
(
Dictionary
<
string
,
string
>
rules
)
{
...
...
Tools.Agent/Forms/DashChart.cs
0 → 100644
View file @
5b9144c4
using
System
;
using
System.Drawing
;
using
System.Windows.Forms
;
namespace
Tools.MyAgent.Forms
{
/// <summary>
/// 看板自绘图表(柱状 / 横向条形)。零第三方依赖:GDI+ 直接画,约 120 行。
/// <para>不开 DoubleBuffered 会闪;不画网格线会看不出量级 —— 两者都保留。</para>
/// </summary>
public
sealed
class
DashChart
:
Panel
{
/// <summary>柱标签(如小时 "00".."23" 或工具名)。</summary>
public
string
[]
Labels
=
new
string
[
0
];
/// <summary>主序列数值。</summary>
public
double
[]
Values
=
new
double
[
0
];
/// <summary>次序列数值(叠加,如失败数);可为空。</summary>
public
double
[]
Values2
=
new
double
[
0
];
/// <summary>是否横向条形图(工具排名用)。</summary>
public
bool
Horizontal
{
get
;
set
;
}
/// <summary>空数据占位文案。</summary>
public
string
EmptyText
{
get
;
set
;
}
=
"暂无数据"
;
/// <summary>图表标题 / 坐标轴说明;非空时在顶部绘制白底说明条(避免与最高柱重叠)。</summary>
public
string
Caption
{
get
;
set
;
}
=
""
;
public
Color
BarColor
{
get
;
set
;
}
=
Color
.
FromArgb
(
29
,
158
,
117
);
public
Color
BarColor2
{
get
;
set
;
}
=
Color
.
FromArgb
(
226
,
75
,
74
);
public
DashChart
()
{
SetStyle
(
ControlStyles
.
AllPaintingInWmPaint
|
ControlStyles
.
UserPaint
|
ControlStyles
.
OptimizedDoubleBuffer
|
ControlStyles
.
ResizeRedraw
,
true
);
BackColor
=
Color
.
White
;
}
protected
override
void
OnPaint
(
PaintEventArgs
e
)
{
try
{
base
.
OnPaint
(
e
);
var
g
=
e
.
Graphics
;
g
.
SmoothingMode
=
System
.
Drawing
.
Drawing2D
.
SmoothingMode
.
AntiAlias
;
g
.
Clear
(
BackColor
);
if
(
Values
==
null
||
Values
.
Length
==
0
||
Labels
==
null
||
Labels
.
Length
==
0
)
{
using
(
var
b
=
new
SolidBrush
(
Color
.
Gray
))
using
(
var
f
=
new
Font
(
FontFamily
.
GenericSansSerif
,
9f
))
g
.
DrawString
(
EmptyText
,
f
,
b
,
8
,
Height
/
2
-
8
);
return
;
}
int
n
=
Math
.
Min
(
Values
.
Length
,
Labels
.
Length
);
double
max
=
0
;
for
(
int
i
=
0
;
i
<
n
;
i
++)
{
if
(
Values
[
i
]
>
max
)
max
=
Values
[
i
];
if
(
Values2
!=
null
&&
i
<
Values2
.
Length
&&
Values2
[
i
]
>
max
)
max
=
Values2
[
i
];
}
if
(
max
<=
0
)
max
=
1
;
const
float
baseline
=
18f
;
// 柱底留白
const
float
axisTopPad
=
4f
;
// 顶部网格留白
int
topPad
=
string
.
IsNullOrEmpty
(
Caption
)
?
0
:
16
;
// 说明条占用
float
baseY
=
Height
-
baseline
;
float
avail
=
Height
-
baseline
-
axisTopPad
-
topPad
;
if
(
avail
<
10
)
avail
=
10
;
using
(
var
gridPen
=
new
Pen
(
Color
.
FromArgb
(
230
,
230
,
230
)))
using
(
var
textBrush
=
new
SolidBrush
(
Color
.
FromArgb
(
90
,
90
,
90
)))
using
(
var
font
=
new
Font
(
FontFamily
.
GenericSansSerif
,
8f
))
using
(
var
capBrush
=
new
SolidBrush
(
Color
.
FromArgb
(
110
,
110
,
110
)))
using
(
var
capFont
=
new
Font
(
FontFamily
.
GenericSansSerif
,
8.5f
))
{
// 网格(4 等分)
for
(
int
k
=
1
;
k
<=
4
;
k
++)
{
float
y
=
baseY
-
avail
*
k
/
4f
;
g
.
DrawLine
(
gridPen
,
4
,
y
,
Width
-
4
,
y
);
}
// 标题 / 坐标轴说明(白底,避免与最高柱重叠)
if
(
topPad
>
0
&&
!
string
.
IsNullOrEmpty
(
Caption
))
{
var
capSize
=
g
.
MeasureString
(
Caption
,
capFont
);
g
.
FillRectangle
(
Brushes
.
White
,
2
,
1
,
capSize
.
Width
+
6
,
14
);
g
.
DrawString
(
Caption
,
capFont
,
capBrush
,
4
,
2
);
}
if
(
Horizontal
)
{
float
rowH
=
(
Height
-
12f
)
/
n
;
for
(
int
i
=
0
;
i
<
n
;
i
++)
{
float
y
=
6
+
i
*
rowH
;
float
w
=
(
float
)(
Values
[
i
]
/
max
*
(
Width
-
130
));
using
(
var
br
=
new
SolidBrush
(
BarColor
))
g
.
FillRectangle
(
br
,
100
,
y
+
rowH
*
0.2f
,
Math
.
Max
(
5
,
w
),
rowH
*
0.6f
);
g
.
DrawString
(
Trim
(
Labels
[
i
],
14
),
font
,
textBrush
,
4
,
y
+
rowH
*
0.25f
);
g
.
DrawString
(
Fmt
(
Values
[
i
]),
font
,
textBrush
,
100
+
w
+
4
,
y
+
rowH
*
0.25f
);
}
}
else
{
float
slot
=
(
Width
-
12f
)
/
n
;
float
bw
=
Math
.
Max
(
2
,
slot
*
0.62f
);
for
(
int
i
=
0
;
i
<
n
;
i
++)
{
double
frac
=
BarFrac
(
Values
[
i
],
max
);
float
h1
=
(
float
)(
frac
*
avail
);
h1
=
Math
.
Max
(
5
,
h1
);
// 每柱默认高度 5(0 值也可见)
float
x
=
6
+
i
*
slot
+
(
slot
-
bw
)
/
2
;
using
(
var
br
=
new
SolidBrush
(
BarColor
))
g
.
FillRectangle
(
br
,
x
,
baseY
-
h1
,
bw
,
h1
);
float
h2
=
0
;
if
(
Values2
!=
null
&&
i
<
Values2
.
Length
&&
Values2
[
i
]
>
0
)
{
double
frac2
=
BarFrac
(
Values2
[
i
],
max
);
h2
=
(
float
)(
frac2
*
avail
);
h2
=
Math
.
Max
(
1
,
h2
);
using
(
var
br
=
new
SolidBrush
(
BarColor2
))
g
.
FillRectangle
(
br
,
x
,
baseY
-
h1
-
h2
,
bw
,
h2
);
}
if
(
n
<=
24
)
g
.
DrawString
(
Labels
[
i
],
font
,
textBrush
,
x
,
baseY
+
1
);
// 柱顶计数值(仅非零,零柱不标以区分)
if
(
Values
[
i
]
>
0
)
{
string
tag
=
Fmt
(
Values
[
i
]);
var
tagSize
=
g
.
MeasureString
(
tag
,
font
);
float
ty
=
baseY
-
h1
-
h2
-
12
;
if
(
ty
<
topPad
+
1
)
ty
=
topPad
+
1
;
g
.
DrawString
(
tag
,
font
,
textBrush
,
x
+
(
bw
-
tagSize
.
Width
)
/
2
,
ty
);
}
}
}
}
}
catch
{
}
}
/// <summary>柱高比例:0 → 0;其余按 log2 倍增刻度(每翻倍增量恒定),避免小计次不可见、大计次一家独大。</summary>
private
static
double
BarFrac
(
double
v
,
double
max
)
{
if
(
v
<=
0
)
return
0
;
if
(
max
<=
1
)
return
1
;
return
Math
.
Log
(
v
+
1
,
2
)
/
Math
.
Log
(
max
+
1
,
2
);
}
private
static
string
Trim
(
string
s
,
int
max
)
{
if
(
string
.
IsNullOrEmpty
(
s
))
return
""
;
return
s
.
Length
<=
max
?
s
:
s
.
Substring
(
0
,
max
-
1
)
+
"…"
;
}
private
static
string
Fmt
(
double
v
)
{
if
(
v
>=
1000
)
return
(
v
/
1000.0
).
ToString
(
"0.0"
)
+
"k"
;
return
((
long
)
v
).
ToString
();
}
}
}
Tools.Agent/Kb/KbService.cs
View file @
5b9144c4
...
...
@@ -410,6 +410,18 @@ namespace Tools.MyAgent.Kb
/// <summary>搜索范围是否有效(目录存在)</summary>
public
bool
IsSearchValid
=>
!
string
.
IsNullOrEmpty
(
SearchRoot
)
&&
Directory
.
Exists
(
SearchRoot
);
/// <summary>
/// 索引是否已就绪(未启动 / 构建中为 false)。供可观测性健康检查读取,不改变任何检索行为。
/// </summary>
public
bool
IndexReady
{
get
{
try
{
return
_index
!=
null
&&
_index
.
IsReady
;
}
catch
{
return
false
;
}
}
}
private
const
int
AutoIndexFileThreshold
=
2000
;
/// <summary>
...
...
Tools.Agent/Support/AgentHealth.cs
0 → 100644
View file @
5b9144c4
using
Newtonsoft.Json
;
using
System
;
using
System.Collections.Generic
;
using
System.IO
;
using
System.Text
;
using
Tools.MyAgent.Agent
;
using
Tools.MyAgent.Config
;
using
Tools.MyAgent.Diagnostics
;
using
Tools.MyAgent.Kb
;
namespace
Tools.MyAgent
{
/// <summary>健康检查结果项。</summary>
public
sealed
class
HealthItem
{
/// <summary>检查项标识(稳定,便于聚合与对比)。</summary>
public
string
Id
=
""
;
/// <summary>展示名。</summary>
public
string
Name
=
""
;
/// <summary>ok / warn / fail。</summary>
public
string
Status
=
"ok"
;
/// <summary>判定依据或处置建议。</summary>
public
string
Detail
=
""
;
public
override
string
ToString
()
=>
$"[
{
Status
}
]
{
Name
}
:
{
Detail
}
"
;
}
/// <summary>
/// 依赖健康检查(9 项)。按需执行,不定时轮询;单项异常只影响该项。
/// <para>输出:<c>Logs/health.json</c> + 聊天窗 <c>/diag</c> 的表格 + Admin 看板 ⑤Tab。</para>
/// </summary>
public
static
class
AgentHealth
{
/// <summary>日志目录超该大小时告警(防止观测自身撑爆磁盘)。</summary>
private
const
long
LogsWarnBytes
=
256L
*
1024
*
1024
;
/// <summary>
/// 执行检查(不含模型服务连通性——它是异步的,由调用方 <see cref="ModelService"/> 单独追加)。
/// </summary>
public
static
HealthItem
[]
Check
(
AgentConfig
cfg
,
KbService
kb
,
IReadOnlyList
<
DiagnosticRoot
>
roots
)
{
var
list
=
new
List
<
HealthItem
>();
try
{
list
.
Add
(
CheckConfig
(
cfg
));
list
.
Add
(
CheckPrice
(
cfg
));
list
.
Add
(
CheckKbRoot
(
kb
));
list
.
Add
(
CheckKbScope
(
kb
));
list
.
Add
(
CheckIndex
(
kb
));
list
.
Add
(
CheckSemantic
(
cfg
));
list
.
Add
(
CheckDiagnosticRoots
(
roots
));
list
.
Add
(
CheckTelemetrySelf
());
}
catch
{
}
return
list
.
ToArray
();
}
/// <summary>模型服务连通性项(由调用方用 <c>TestConnectionAsync</c> 结果构造)。</summary>
public
static
HealthItem
ModelService
(
bool
ok
,
string
message
)
{
return
new
HealthItem
{
Id
=
"model_service"
,
Name
=
"模型服务"
,
Status
=
ok
?
"ok"
:
"fail"
,
Detail
=
string
.
IsNullOrEmpty
(
message
)
?
(
ok
?
"连通"
:
"未知"
)
:
message
,
};
}
private
static
HealthItem
CheckConfig
(
AgentConfig
cfg
)
{
try
{
if
(
cfg
==
null
)
return
Item
(
"config"
,
"配置完整性"
,
"fail"
,
"配置对象为空"
);
var
miss
=
new
List
<
string
>();
if
(
string
.
IsNullOrWhiteSpace
(
cfg
.
ApiKey
))
miss
.
Add
(
"API Key"
);
if
(
string
.
IsNullOrWhiteSpace
(
cfg
.
Model
))
miss
.
Add
(
"模型名"
);
if
(
string
.
IsNullOrWhiteSpace
(
cfg
.
BaseUrl
))
miss
.
Add
(
"API 基地址"
);
else
if
(!(
cfg
.
BaseUrl
.
StartsWith
(
"http://"
,
StringComparison
.
OrdinalIgnoreCase
)
||
cfg
.
BaseUrl
.
StartsWith
(
"https://"
,
StringComparison
.
OrdinalIgnoreCase
)))
miss
.
Add
(
"基地址格式"
);
return
miss
.
Count
==
0
?
Item
(
"config"
,
"配置完整性"
,
"ok"
,
cfg
.
Model
+
" @ "
+
cfg
.
BaseUrl
)
:
Item
(
"config"
,
"配置完整性"
,
"fail"
,
"缺少:"
+
string
.
Join
(
"、"
,
miss
));
}
catch
(
Exception
ex
)
{
return
Item
(
"config"
,
"配置完整性"
,
"warn"
,
ex
.
Message
);
}
}
private
static
HealthItem
CheckPrice
(
AgentConfig
cfg
)
{
try
{
if
(
cfg
==
null
||
string
.
IsNullOrEmpty
(
cfg
.
Model
))
return
Item
(
"price"
,
"费用可见性"
,
"warn"
,
"未配置模型"
);
var
p
=
CostEstimator
.
FindPrice
(
cfg
.
Model
);
return
p
==
null
?
Item
(
"price"
,
"费用可见性"
,
"warn"
,
"模型未收录价格表,费用显示为「—」(可用 CustomPrices 配置)"
)
:
Item
(
"price"
,
"费用可见性"
,
"ok"
,
$"输入 ¥
{
p
.
InputPer1M
}
/M · 输出 ¥
{
p
.
OutputPer1M
}
/M"
);
}
catch
(
Exception
ex
)
{
return
Item
(
"price"
,
"费用可见性"
,
"warn"
,
ex
.
Message
);
}
}
private
static
HealthItem
CheckKbRoot
(
KbService
kb
)
{
try
{
if
(
kb
==
null
)
return
Item
(
"kb_root"
,
"知识库可访问"
,
"fail"
,
"知识库服务未初始化"
);
if
(
kb
.
IsValid
)
return
Item
(
"kb_root"
,
"知识库可访问"
,
"ok"
,
kb
.
Root
);
if
(
string
.
IsNullOrEmpty
(
kb
.
Root
))
return
Item
(
"kb_root"
,
"知识库可访问"
,
"warn"
,
"未选择知识库"
);
return
Item
(
"kb_root"
,
"知识库可访问"
,
"fail"
,
kb
.
Root
.
StartsWith
(
@"\\"
,
StringComparison
.
Ordinal
)
?
"共享路径不可访问(检查服务器/共享名/账号密码):"
+
kb
.
Root
:
"本地路径不存在:"
+
kb
.
Root
);
}
catch
(
Exception
ex
)
{
return
Item
(
"kb_root"
,
"知识库可访问"
,
"fail"
,
ex
.
Message
);
}
}
private
static
HealthItem
CheckKbScope
(
KbService
kb
)
{
try
{
if
(
kb
==
null
)
return
Item
(
"kb_scope"
,
"搜索范围"
,
"warn"
,
"知识库服务未初始化"
);
if
(
kb
.
IsSearchValid
)
return
Item
(
"kb_scope"
,
"搜索范围"
,
"ok"
,
string
.
IsNullOrEmpty
(
kb
.
Scope
)
?
"全部(未限定)"
:
kb
.
Scope
);
return
Item
(
"kb_scope"
,
"搜索范围"
,
"warn"
,
"限定目录不可访问:"
+
kb
.
Scope
);
}
catch
(
Exception
ex
)
{
return
Item
(
"kb_scope"
,
"搜索范围"
,
"warn"
,
ex
.
Message
);
}
}
private
static
HealthItem
CheckIndex
(
KbService
kb
)
{
try
{
if
(
kb
==
null
)
return
Item
(
"kb_index"
,
"索引状态"
,
"warn"
,
"知识库服务未初始化"
);
return
kb
.
IndexReady
?
Item
(
"kb_index"
,
"索引状态"
,
"ok"
,
"已就绪"
)
:
Item
(
"kb_index"
,
"索引状态"
,
"warn"
,
"未就绪(首次检索会惰性构建,或构建进行中)"
);
}
catch
(
Exception
ex
)
{
return
Item
(
"kb_index"
,
"索引状态"
,
"warn"
,
ex
.
Message
);
}
}
private
static
HealthItem
CheckSemantic
(
AgentConfig
cfg
)
{
try
{
if
(
cfg
==
null
||
!
cfg
.
EnableSemanticSearch
)
return
Item
(
"semantic"
,
"语义检索"
,
"ok"
,
"已关闭"
);
if
(
string
.
IsNullOrWhiteSpace
(
cfg
.
EmbeddingBaseUrl
))
return
Item
(
"semantic"
,
"语义检索"
,
"warn"
,
"已启用但未配置 Embedding 地址"
);
return
Item
(
"semantic"
,
"语义检索"
,
"ok"
,
cfg
.
EmbeddingModel
+
" @ "
+
cfg
.
EmbeddingBaseUrl
);
}
catch
(
Exception
ex
)
{
return
Item
(
"semantic"
,
"语义检索"
,
"warn"
,
ex
.
Message
);
}
}
private
static
HealthItem
CheckDiagnosticRoots
(
IReadOnlyList
<
DiagnosticRoot
>
roots
)
{
try
{
if
(
roots
==
null
||
roots
.
Count
==
0
)
return
Item
(
"diag_roots"
,
"诊断根目录"
,
"ok"
,
"未注入(按需能力)"
);
var
bad
=
new
List
<
string
>();
foreach
(
var
r
in
roots
)
{
if
(
r
==
null
||
string
.
IsNullOrEmpty
(
r
.
Path
))
continue
;
if
(!
Directory
.
Exists
(
r
.
Path
))
bad
.
Add
(
r
.
Id
);
}
return
bad
.
Count
==
0
?
Item
(
"diag_roots"
,
"诊断根目录"
,
"ok"
,
roots
.
Count
+
" 个均已就绪"
)
:
Item
(
"diag_roots"
,
"诊断根目录"
,
"warn"
,
"不可访问:"
+
string
.
Join
(
"、"
,
bad
));
}
catch
(
Exception
ex
)
{
return
Item
(
"diag_roots"
,
"诊断根目录"
,
"warn"
,
ex
.
Message
);
}
}
/// <summary>观测自身:日志目录是否膨胀(自查项,防止观测把磁盘撑爆)。</summary>
private
static
HealthItem
CheckTelemetrySelf
()
{
try
{
string
dir
=
Path
.
Combine
(
AppDomain
.
CurrentDomain
.
BaseDirectory
,
"Logs"
);
if
(!
Directory
.
Exists
(
dir
))
return
Item
(
"telemetry_self"
,
"观测自身"
,
"ok"
,
"尚无日志"
);
long
size
=
0
;
int
count
=
0
;
foreach
(
var
f
in
Directory
.
GetFiles
(
dir
,
"*.*"
,
SearchOption
.
AllDirectories
))
{
try
{
size
+=
new
FileInfo
(
f
).
Length
;
count
++;
}
catch
{
}
}
string
text
=
$"
{
count
}
个文件 /
{
size
/
1024
/
1024
}
MB"
;
return
size
>
LogsWarnBytes
?
Item
(
"telemetry_self"
,
"观测自身"
,
"warn"
,
text
+
"(超过告警阈值,检查保留期配置)"
)
:
Item
(
"telemetry_self"
,
"观测自身"
,
"ok"
,
text
);
}
catch
(
Exception
ex
)
{
return
Item
(
"telemetry_self"
,
"观测自身"
,
"warn"
,
ex
.
Message
);
}
}
private
static
HealthItem
Item
(
string
id
,
string
name
,
string
status
,
string
detail
)
{
return
new
HealthItem
{
Id
=
id
,
Name
=
name
,
Status
=
status
,
Detail
=
detail
};
}
/// <summary>序列化为 JSON(<c>Logs/health.json</c>)。</summary>
public
static
string
ToJson
(
HealthItem
[]
items
)
{
try
{
var
doc
=
new
Dictionary
<
string
,
object
>
{
[
"ts"
]
=
DateTime
.
UtcNow
.
ToString
(
"o"
),
[
"items"
]
=
items
??
new
HealthItem
[
0
],
};
return
JsonConvert
.
SerializeObject
(
doc
,
Formatting
.
Indented
);
}
catch
{
return
"{}"
;
}
}
/// <summary>落盘到 <c>Logs/health.json</c>(覆盖写);失败不影响调用方。</summary>
public
static
string
Save
(
HealthItem
[]
items
)
{
try
{
string
dir
=
Path
.
Combine
(
AppDomain
.
CurrentDomain
.
BaseDirectory
,
"Logs"
);
Directory
.
CreateDirectory
(
dir
);
string
path
=
Path
.
Combine
(
dir
,
"health.json"
);
File
.
WriteAllText
(
path
,
ToJson
(
items
),
new
UTF8Encoding
(
false
));
return
path
;
}
catch
{
return
""
;
}
}
/// <summary>渲染为聊天窗 Markdown(分组表格;<c>/diag</c> 用)。</summary>
public
static
string
RenderMarkdown
(
HealthItem
[]
items
)
{
var
sb
=
new
StringBuilder
();
try
{
if
(
items
==
null
||
items
.
Length
==
0
)
return
"(健康检查无结果)"
;
int
fail
=
0
,
warn
=
0
;
foreach
(
var
i
in
items
)
{
if
(
i
.
Status
==
"fail"
)
fail
++;
else
if
(
i
.
Status
==
"warn"
)
warn
++;
}
sb
.
Append
(
"**依赖健康检查**("
).
Append
(
items
.
Length
).
Append
(
" 项:"
)
.
Append
(
fail
).
Append
(
" 失败 / "
).
Append
(
warn
).
Append
(
" 告警)\n\n"
);
sb
.
Append
(
"| 状态 | 检查项 | 说明 |\n|---|---|---|\n"
);
foreach
(
var
i
in
items
)
sb
.
Append
(
"| "
).
Append
(
Mark
(
i
.
Status
)).
Append
(
" | "
).
Append
(
i
.
Name
).
Append
(
" | "
)
.
Append
((
i
.
Detail
??
""
).
Replace
(
"|"
,
"/"
).
Replace
(
"\n"
,
" "
)).
Append
(
" |\n"
);
}
catch
{
}
return
sb
.
ToString
();
}
private
static
string
Mark
(
string
status
)
{
if
(
status
==
"fail"
)
return
"❌"
;
if
(
status
==
"warn"
)
return
"⚠️"
;
return
"✅"
;
}
}
}
Tools.Agent/Support/AgentTelemetry.cs
0 → 100644
View file @
5b9144c4
using
Newtonsoft.Json
;
using
System
;
using
System.Collections.Concurrent
;
using
System.Collections.Generic
;
using
System.Diagnostics
;
using
System.IO
;
using
System.Text
;
using
System.Threading
;
namespace
Tools.MyAgent
{
/// <summary>
/// 智能体可观测性出口(Trace / Metrics)。
/// <para>红线:本类所有 public 方法体在最外层 try/catch —— **不得有向上传播的 throw**,
/// 观测任何异常都不得影响正常回答。</para>
/// <para>与 <see cref="AgentLogger"/> 的分工:Logger 写「给人看的文本日志」;
/// 本类写「可聚合的结构化数据」(一轮一条 RunScope + 计数器/直方图)。</para>
/// </summary>
public
static
class
AgentTelemetry
{
// ══════════════ 契约类型(内嵌,避免新增文件) ══════════════
/// <summary>一轮提问的运行时容器(对应一次 <c>AgentEngine.RunAsync</c>)。</summary>
public
sealed
class
RunScope
{
/// <summary>运行唯一标识(复用引擎的 <c>_diagnosticSessionId</c>)。</summary>
public
string
RunId
=
""
;
/// <summary>起始时间(ISO8601 UTC)。</summary>
public
string
StartedAt
=
""
;
/// <summary>提问字符数(不存原文)。</summary>
public
int
QuestionChars
;
/// <summary>提问哈希(SHA256 前 12 位;一轮算一次,非 hot path)。</summary>
public
string
QuestionHash
=
""
;
/// <summary>配置模型。</summary>
public
string
Model
=
""
;
/// <summary>实际服务模型(故障转移时为备用模型)。</summary>
public
string
ServedModel
=
""
;
/// <summary>回答通道:kb / mixed / model / tool-loop。</summary>
public
string
Mode
=
""
;
/// <summary>回答类型(<c>AnswerSpec.Classify</c> 结果)。</summary>
public
string
AnswerKind
=
""
;
public
int
ToolRounds
;
public
int
ToolCalls
;
public
int
LlmCalls
;
public
int
PromptTokens
;
public
int
CompletionTokens
;
public
double
Cost
;
public
long
TotalMs
;
public
long
ToolMs
;
public
long
LlmMs
;
/// <summary>首包延迟(首次 LLM 调用的首个有效 delta)。</summary>
public
long
TtfbMs
;
public
int
FailoverCount
;
public
int
ContinuationCount
;
public
int
ContextTrimCount
;
/// <summary>success / error / cancel。</summary>
public
string
Outcome
=
""
;
/// <summary>auth|quota|ratelimit|network|context_length|timeout|unknown。</summary>
public
string
ErrorType
=
""
;
/// <summary>是否走到自然收尾(未被取消/异常中断)。</summary>
public
bool
Completed
;
/// <summary>span 达到上限被截断采集(ToolSpan ≤ 60 / LlmSpan ≤ 20)。</summary>
public
bool
SpansTruncated
;
[
JsonIgnore
]
public
Stopwatch
Sw
;
[
JsonIgnore
]
public
readonly
List
<
ToolSpan
>
Tools
=
new
List
<
ToolSpan
>();
[
JsonIgnore
]
public
readonly
List
<
LlmSpan
>
Llms
=
new
List
<
LlmSpan
>();
public
override
string
ToString
()
{
return
$"
{
Outcome
}
/
{
Mode
}
{
TotalMs
}
ms tools=
{
ToolCalls
}
llm=
{
LlmCalls
}
"
;
}
}
/// <summary>一次工具调用(对 <c>AgentEngine.ToolTraceEntry</c> 的超集,不替换它)。</summary>
public
sealed
class
ToolSpan
{
public
string
RunId
=
""
;
public
int
Seq
;
public
int
Round
;
public
string
Name
=
""
;
/// <summary>参数摘要(经 <see cref="AgentLogger.Mask"/> 后截断 80 字)。</summary>
public
string
ArgsSummary
=
""
;
/// <summary>相对本轮起点的偏移(ms)。</summary>
public
long
StartOffsetMs
;
public
long
DurationMs
;
/// <summary>ok / empty / error / timeout / cancel / denied。</summary>
public
string
Outcome
=
""
;
public
int
ResultChars
;
/// <summary>结果被 <c>CompressToolResult</c> 截断。</summary>
public
bool
Truncated
;
public
string
ErrorType
=
""
;
public
override
string
ToString
()
=>
$"
{
Name
}
(
{
ArgsSummary
}
)
{
DurationMs
}
ms
{
Outcome
}
"
;
}
/// <summary>一次 LLM 请求(含工具轮 / 收尾 / 续写 / 直答 / 标题 / 摘要)。</summary>
public
sealed
class
LlmSpan
{
public
string
RunId
=
""
;
public
int
Seq
;
/// <summary>tool-round / finish / continuation / direct / title / summarize。</summary>
public
string
Kind
=
""
;
public
string
Model
=
""
;
public
long
TtfbMs
;
public
long
DurationMs
;
public
int
PromptTokens
;
public
int
CompletionTokens
;
public
string
FinishReason
=
""
;
public
int
RetryCount
;
public
string
Outcome
=
""
;
public
string
ErrorType
=
""
;
public
override
string
ToString
()
=>
$"
{
Kind
}
{
Model
}
{
DurationMs
}
ms
{
Outcome
}
"
;
}
/// <summary>直方图读数(固定桶插值,±半桶误差)。</summary>
public
sealed
class
HistogramView
{
public
long
Count
;
public
long
Sum
;
public
long
Min
;
public
long
Max
;
public
long
P50
;
public
long
P95
;
public
override
string
ToString
()
=>
$"n=
{
Count
}
p50=
{
P50
}
p95=
{
P95
}
max=
{
Max
}
"
;
}
/// <summary>内存快照(<c>/stats</c> 与看板读这个)。</summary>
public
sealed
class
RunStats
{
public
long
Runs
;
public
long
Success
;
public
long
Errors
;
public
long
Cancels
;
public
long
ToolCalls
;
public
long
LlmCalls
;
public
long
PromptTokens
;
public
long
CompletionTokens
;
public
double
Cost
;
public
long
Dropped
;
public
HistogramView
Duration
=
new
HistogramView
();
public
HistogramView
Ttfb
=
new
HistogramView
();
/// <summary>计数器全量副本(键形如 <c>agent_runs_total|outcome=success</c>)。</summary>
public
Dictionary
<
string
,
long
>
Counters
=
new
Dictionary
<
string
,
long
>();
}
// ══════════════ 状态 ══════════════
private
static
readonly
object
Lock
=
new
object
();
private
static
readonly
ConcurrentDictionary
<
string
,
long
>
Counters
=
new
ConcurrentDictionary
<
string
,
long
>(
StringComparer
.
OrdinalIgnoreCase
);
/// <summary>直方图桶上界(ms):50/100/200/400/800/1600/3200/6400/12800/25600/51200/+∞</summary>
private
static
readonly
long
[]
Buckets
=
{
50
,
100
,
200
,
400
,
800
,
1600
,
3200
,
6400
,
12800
,
25600
,
51200
,
long
.
MaxValue
};
private
sealed
class
Hist
{
public
readonly
long
[]
Counts
=
new
long
[
12
];
public
long
Sum
;
public
long
Min
=
long
.
MaxValue
;
public
long
Max
;
public
long
Total
;
}
private
static
readonly
ConcurrentDictionary
<
string
,
Hist
>
Histograms
=
new
ConcurrentDictionary
<
string
,
Hist
>(
StringComparer
.
OrdinalIgnoreCase
);
private
const
int
MaxToolSpans
=
60
;
private
const
int
MaxLlmSpans
=
20
;
private
static
bool
_enabled
=
true
;
private
static
string
_level
=
"basic"
;
// off|basic|detailed
public
static
bool
Enabled
=>
_enabled
;
/// <summary>隐私级别:off / basic / detailed。</summary>
public
static
string
Level
=>
_level
;
/// <summary>是否写入提问原文类数据(仅 detailed)。</summary>
public
static
bool
IsDetailed
=>
_enabled
&&
string
.
Equals
(
_level
,
"detailed"
,
StringComparison
.
OrdinalIgnoreCase
);
/// <summary>
/// 应用配置。任何非法值一律回退默认,不抛异常。
/// </summary>
public
static
void
Configure
(
bool
enabled
,
string
level
,
string
logsRoot
=
null
)
{
try
{
lock
(
Lock
)
{
_enabled
=
enabled
;
_level
=
string
.
Equals
(
level
,
"detailed"
,
StringComparison
.
OrdinalIgnoreCase
)
?
"detailed"
:
string
.
Equals
(
level
,
"off"
,
StringComparison
.
OrdinalIgnoreCase
)
?
"off"
:
"basic"
;
if
(!
string
.
IsNullOrEmpty
(
logsRoot
))
_logsRoot
=
logsRoot
;
}
if
(
_enabled
&&
!
string
.
Equals
(
_level
,
"off"
,
StringComparison
.
OrdinalIgnoreCase
))
EnsureWriter
();
}
catch
{
}
}
// ══════════════ Run 生命周期 ══════════════
/// <summary>开启一轮运行;关闭观测时返回 null(调用方须判空,用 <c>??</c> 安全调用)。</summary>
public
static
RunScope
BeginRun
(
int
questionChars
,
string
questionHash
,
string
model
)
{
try
{
if
(!
_enabled
)
return
null
;
var
s
=
new
RunScope
{
RunId
=
Guid
.
NewGuid
().
ToString
(
"N"
),
// 引擎侧会用现有 _diagnosticSessionId 覆写
StartedAt
=
DateTime
.
UtcNow
.
ToString
(
"o"
),
QuestionChars
=
questionChars
,
QuestionHash
=
questionHash
??
""
,
Model
=
model
??
""
,
ServedModel
=
model
??
""
,
Sw
=
Stopwatch
.
StartNew
(),
};
return
s
;
}
catch
{
return
null
;
}
}
/// <summary>结束一轮运行:结算耗时、累计指标、触发 <see cref="OnRunCompleted"/>。</summary>
public
static
void
EndRun
(
RunScope
s
,
string
outcome
,
string
errorType
,
Exception
ex
)
{
try
{
if
(
s
==
null
||
!
_enabled
)
return
;
if
(
s
.
Sw
!=
null
)
{
s
.
Sw
.
Stop
();
s
.
TotalMs
=
s
.
Sw
.
ElapsedMilliseconds
;
}
s
.
Outcome
=
string
.
IsNullOrEmpty
(
outcome
)
?
"unknown"
:
outcome
;
s
.
ErrorType
=
errorType
??
""
;
s
.
Completed
=
string
.
Equals
(
s
.
Outcome
,
"success"
,
StringComparison
.
OrdinalIgnoreCase
);
if
(
string
.
IsNullOrEmpty
(
s
.
Mode
))
s
.
Mode
=
"tool-loop"
;
Count
(
"agent_runs_total"
);
Count
(
"agent_runs_total|outcome="
+
s
.
Outcome
);
Count
(
"agent_runs_by_mode|mode="
+
(
string
.
IsNullOrEmpty
(
s
.
Mode
)
?
"unknown"
:
s
.
Mode
));
if
(!
string
.
IsNullOrEmpty
(
s
.
AnswerKind
))
Count
(
"agent_runs_by_kind|kind="
+
s
.
AnswerKind
);
if
(!
string
.
IsNullOrEmpty
(
s
.
ErrorType
))
Count
(
"agent_runs_by_error|errorType="
+
s
.
ErrorType
);
// ⚠ 不能写成 > 0:亚毫秒级的一轮会被静默丢弃,直方图样本数偏少
if
(
s
.
TotalMs
>=
0
)
Observe
(
"agent_run_duration_ms"
,
s
.
TotalMs
);
// 用量与费用(费用按「毫分」整数累计,避免计数器用浮点)
if
(
s
.
PromptTokens
>
0
)
Count
(
"agent_tokens_total|dir=prompt"
,
s
.
PromptTokens
);
if
(
s
.
CompletionTokens
>
0
)
Count
(
"agent_tokens_total|dir=completion"
,
s
.
CompletionTokens
);
if
(
s
.
Cost
>
0
)
Count
(
"agent_cost_total|model="
+
(
string
.
IsNullOrEmpty
(
s
.
ServedModel
)
?
"unknown"
:
s
.
ServedModel
),
(
long
)(
s
.
Cost
*
10000
));
if
(
s
.
TtfbMs
>
0
)
Observe
(
"agent_ttfb_ms"
,
s
.
TtfbMs
);
EnqueueRun
(
s
);
try
{
OnRunCompleted
?.
Invoke
(
s
);
}
catch
{
}
}
catch
{
}
}
/// <summary>记录一次工具调用(<c>ExecuteTool</c> / <c>ExecuteToolAsync</c> 调用)。</summary>
public
static
void
RecordTool
(
RunScope
s
,
ToolSpan
span
)
{
try
{
if
(
span
==
null
)
return
;
Count
(
"agent_tool_calls_total|name="
+
(
span
.
Name
??
"unknown"
));
Count
(
"agent_tool_calls_total|name="
+
(
span
.
Name
??
"unknown"
)
+
"|outcome="
+
(
span
.
Outcome
??
"unknown"
));
if
(
span
.
Truncated
)
Count
(
"agent_tool_truncated_total|name="
+
(
span
.
Name
??
"unknown"
));
if
(
span
.
DurationMs
>
0
)
Observe
(
"agent_tool_duration_ms|name="
+
(
span
.
Name
??
"unknown"
),
span
.
DurationMs
);
if
(
s
==
null
||
!
_enabled
)
return
;
s
.
ToolCalls
++;
s
.
ToolMs
+=
span
.
DurationMs
;
span
.
Seq
=
s
.
Tools
.
Count
+
1
;
if
(
s
.
Tools
.
Count
<
MaxToolSpans
)
s
.
Tools
.
Add
(
span
);
else
s
.
SpansTruncated
=
true
;
try
{
OnToolSpan
?.
Invoke
(
span
);
}
catch
{
}
}
catch
{
}
}
/// <summary>记录一次 LLM 请求(<c>SendAndLogAsync</c> 调用)。</summary>
public
static
void
RecordLlm
(
RunScope
s
,
LlmSpan
span
)
{
try
{
if
(
span
==
null
)
return
;
Count
(
"agent_llm_calls_total|kind="
+
(
span
.
Kind
??
"unknown"
));
if
(!
string
.
IsNullOrEmpty
(
span
.
Outcome
))
Count
(
"agent_llm_calls_total|kind="
+
(
span
.
Kind
??
"unknown"
)
+
"|outcome="
+
span
.
Outcome
);
if
(
span
.
DurationMs
>
0
)
Observe
(
"agent_llm_duration_ms|kind="
+
(
span
.
Kind
??
"unknown"
),
span
.
DurationMs
);
if
(
s
==
null
||
!
_enabled
)
return
;
s
.
LlmCalls
++;
s
.
LlmMs
+=
span
.
DurationMs
;
s
.
PromptTokens
+=
Math
.
Max
(
0
,
span
.
PromptTokens
);
s
.
CompletionTokens
+=
Math
.
Max
(
0
,
span
.
CompletionTokens
);
if
(
s
.
TtfbMs
<=
0
&&
span
.
TtfbMs
>
0
)
s
.
TtfbMs
=
span
.
TtfbMs
;
span
.
Seq
=
s
.
Llms
.
Count
+
1
;
if
(
s
.
Llms
.
Count
<
MaxLlmSpans
)
s
.
Llms
.
Add
(
span
);
else
s
.
SpansTruncated
=
true
;
}
catch
{
}
}
// ══════════════ 计数器 / 直方图 ══════════════
/// <summary>
/// 计数器累加。<paramref name="name"/> 必须由埋点**硬编码**传入,
/// 禁止外部字符串拼接(防止把路径/提问塞进维度导致维度爆炸)。
/// </summary>
public
static
void
Count
(
string
name
,
long
delta
=
1
)
{
try
{
if
(
string
.
IsNullOrEmpty
(
name
)
||
!
_enabled
)
return
;
Counters
.
AddOrUpdate
(
name
,
delta
,
(
k
,
v
)
=>
v
+
delta
);
}
catch
{
}
}
/// <summary>观测一次耗时(ms)到固定桶直方图。</summary>
public
static
void
Observe
(
string
key
,
long
ms
)
{
try
{
if
(
string
.
IsNullOrEmpty
(
key
)
||
!
_enabled
||
ms
<
0
)
return
;
var
h
=
Histograms
.
GetOrAdd
(
key
,
_
=>
new
Hist
());
lock
(
h
)
{
h
.
Total
++;
h
.
Sum
+=
ms
;
if
(
ms
<
h
.
Min
)
h
.
Min
=
ms
;
if
(
ms
>
h
.
Max
)
h
.
Max
=
ms
;
for
(
int
i
=
0
;
i
<
Buckets
.
Length
;
i
++)
{
if
(
ms
<=
Buckets
[
i
])
{
h
.
Counts
[
i
]++;
break
;
}
}
}
}
catch
{
}
}
/// <summary>直方图读数:P50/P95 由固定桶线性插值得到(±半桶误差)。</summary>
public
static
HistogramView
Histogram
(
string
key
)
{
try
{
if
(
string
.
IsNullOrEmpty
(
key
)
||
!
Histograms
.
TryGetValue
(
key
,
out
var
h
))
return
new
HistogramView
();
lock
(
h
)
{
var
v
=
new
HistogramView
{
Count
=
h
.
Total
,
Sum
=
h
.
Sum
,
Min
=
h
.
Min
==
long
.
MaxValue
?
0
:
h
.
Min
,
Max
=
h
.
Max
};
if
(
h
.
Total
==
0
)
return
v
;
v
.
P50
=
Quantile
(
h
,
0.50
);
v
.
P95
=
Quantile
(
h
,
0.95
);
return
v
;
}
}
catch
{
return
new
HistogramView
();
}
}
private
static
long
Quantile
(
Hist
h
,
double
q
)
{
long
target
=
(
long
)
Math
.
Ceiling
(
h
.
Total
*
q
);
long
cum
=
0
;
long
lo
=
0
;
for
(
int
i
=
0
;
i
<
Buckets
.
Length
;
i
++)
{
if
(
h
.
Counts
[
i
]
==
0
)
continue
;
if
(
cum
+
h
.
Counts
[
i
]
>=
target
)
{
// 桶内线性插值:落在本桶内的第 n 个 → lo + (hi-lo) * n/count
long
hi
=
Buckets
[
i
]
==
long
.
MaxValue
?
h
.
Max
:
Buckets
[
i
];
long
n
=
target
-
cum
;
return
lo
+
(
long
)((
hi
-
lo
)
*
(
double
)
n
/
h
.
Counts
[
i
]);
}
cum
+=
h
.
Counts
[
i
];
lo
=
Buckets
[
i
];
}
return
h
.
Max
;
}
/// <summary>内存快照(/stats 与看板读这个)。</summary>
public
static
RunStats
Snapshot
()
{
try
{
var
st
=
new
RunStats
();
st
.
Duration
=
Histogram
(
"agent_run_duration_ms"
);
st
.
Ttfb
=
Histogram
(
"agent_ttfb_ms"
);
st
.
Runs
=
Get
(
"agent_runs_total"
);
st
.
Success
=
Get
(
"agent_runs_total|outcome=success"
);
st
.
Errors
=
Get
(
"agent_runs_total|outcome=error"
);
st
.
Cancels
=
Get
(
"agent_runs_total|outcome=cancel"
);
st
.
Dropped
=
Get
(
"agent_telemetry_dropped"
);
st
.
ToolCalls
=
SumPrefix
(
"agent_tool_calls_total|name="
);
st
.
LlmCalls
=
SumPrefix
(
"agent_llm_calls_total|kind="
);
st
.
PromptTokens
=
Get
(
"agent_tokens_total|dir=prompt"
);
st
.
CompletionTokens
=
Get
(
"agent_tokens_total|dir=completion"
);
double
costMilli
=
0
;
foreach
(
var
kv
in
Counters
)
{
st
.
Counters
[
kv
.
Key
]
=
kv
.
Value
;
if
(
kv
.
Key
.
StartsWith
(
"agent_cost_total"
,
StringComparison
.
OrdinalIgnoreCase
))
costMilli
+=
kv
.
Value
;
}
st
.
Cost
=
costMilli
/
10000.0
;
// 计数器以「毫分」存费用
return
st
;
}
catch
{
return
new
RunStats
();
}
}
/// <summary>取计数器值(不存在返回 0)。</summary>
public
static
long
Get
(
string
name
)
{
try
{
return
Counters
.
TryGetValue
(
name
,
out
var
v
)
?
v
:
0
;
}
catch
{
return
0
;
}
}
/// <summary>按前缀汇总(用于无维度的总量)。</summary>
public
static
long
SumPrefix
(
string
prefix
)
{
try
{
long
sum
=
0
;
foreach
(
var
kv
in
Counters
)
{
if
(!
kv
.
Key
.
StartsWith
(
prefix
,
StringComparison
.
OrdinalIgnoreCase
))
continue
;
// 排除带二级维度的键(如 name=x|outcome=y),避免重复计数
string
tail
=
kv
.
Key
.
Substring
(
prefix
.
Length
);
if
(
tail
.
IndexOf
(
'|'
)
>=
0
)
continue
;
sum
+=
kv
.
Value
;
}
return
sum
;
}
catch
{
return
0
;
}
}
/// <summary>提问哈希(SHA256 前 12 位 hex);一轮算一次,失败返回空串。</summary>
public
static
string
HashQuestion
(
string
question
)
{
try
{
if
(
string
.
IsNullOrEmpty
(
question
))
return
""
;
using
(
var
sha
=
System
.
Security
.
Cryptography
.
SHA256
.
Create
())
{
byte
[]
bytes
=
sha
.
ComputeHash
(
System
.
Text
.
Encoding
.
UTF8
.
GetBytes
(
question
));
var
sb
=
new
System
.
Text
.
StringBuilder
(
12
);
for
(
int
i
=
0
;
i
<
6
&&
i
<
bytes
.
Length
;
i
++)
sb
.
Append
(
bytes
[
i
].
ToString
(
"x2"
));
return
sb
.
ToString
();
}
}
catch
{
return
""
;
}
}
/// <summary>参数摘要:脱敏后截断(默认 80 字)。</summary>
public
static
string
Summarize
(
string
text
,
int
max
=
80
)
{
try
{
if
(
string
.
IsNullOrEmpty
(
text
))
return
""
;
string
s
=
AgentLogger
.
Mask
(
text
).
Replace
(
"\r"
,
" "
).
Replace
(
"\n"
,
" "
);
s
=
s
.
Trim
();
return
s
.
Length
<=
max
?
s
:
s
.
Substring
(
0
,
max
)
+
"…"
;
}
catch
{
return
""
;
}
}
// ══════════════════ 落盘(M2:有界队列 + 单后台写线程)══════════════════
/// <summary>队列容量;满即丢弃并计数,绝不阻塞回答。</summary>
private
const
int
QueueCapacity
=
1000
;
/// <summary>单个 jsonl 的轮转阈值(8MB)。</summary>
private
const
long
MaxFileBytes
=
8L
*
1024
*
1024
;
private
static
readonly
ConcurrentQueue
<
string
>
RunQueue
=
new
ConcurrentQueue
<
string
>();
private
static
readonly
object
_writerLock
=
new
object
();
private
static
Thread
_writerThread
;
private
static
StreamWriter
_runWriter
;
private
static
StreamWriter
_metricWriter
;
private
static
string
_logsRoot
=
""
;
private
static
string
_runPath
=
""
;
private
static
string
_metricPath
=
""
;
private
static
volatile
bool
_writerStop
;
private
static
DateTime
_lastMetricFlush
=
DateTime
.
UtcNow
;
private
static
int
_runsSinceFlush
;
/// <summary>观测数据目录(默认 程序基目录/Logs)。</summary>
public
static
string
LogsRoot
{
get
{
try
{
if
(
string
.
IsNullOrEmpty
(
_logsRoot
))
_logsRoot
=
Path
.
Combine
(
AppDomain
.
CurrentDomain
.
BaseDirectory
,
"Logs"
);
return
_logsRoot
;
}
catch
{
return
"Logs"
;
}
}
}
private
static
void
EnsureWriter
()
{
try
{
if
(
_writerThread
!=
null
)
return
;
lock
(
_writerLock
)
{
if
(
_writerThread
!=
null
)
return
;
Directory
.
CreateDirectory
(
LogsRoot
);
_writerThread
=
new
Thread
(
WriterLoop
);
_writerThread
.
IsBackground
=
true
;
_writerThread
.
Name
=
"agent-telemetry-writer"
;
_writerThread
.
Start
();
AppDomain
.
CurrentDomain
.
ProcessExit
+=
(
s
,
e
)
=>
Flush
();
}
}
catch
{
}
}
private
static
void
WriterLoop
()
{
try
{
while
(!
_writerStop
)
{
string
line
;
if
(
RunQueue
.
TryDequeue
(
out
line
))
{
try
{
var
w
=
GetRunWriter
();
if
(
w
!=
null
)
{
w
.
Write
(
line
);
w
.
Write
(
'\n'
);
}
}
catch
{
}
}
else
Thread
.
Sleep
(
200
);
}
Flush
();
}
catch
{
}
}
/// <summary>追加写(复用常驻 StreamWriter,避免每次 AppendAllText 的开合开销)。</summary>
private
static
StreamWriter
GetRunWriter
()
{
try
{
Directory
.
CreateDirectory
(
LogsRoot
);
if
(
_runWriter
!=
null
&&
File
.
Exists
(
_runPath
)
&&
new
FileInfo
(
_runPath
).
Length
<
MaxFileBytes
)
return
_runWriter
;
CloseRunWriter
();
_runPath
=
NextAvailable
(
"runs_"
+
DateTime
.
Now
.
ToString
(
"yyyyMMdd"
)
+
".jsonl"
);
var
fs
=
new
FileStream
(
_runPath
,
FileMode
.
Append
,
FileAccess
.
Write
,
FileShare
.
ReadWrite
);
_runWriter
=
new
StreamWriter
(
fs
,
new
UTF8Encoding
(
false
))
{
AutoFlush
=
true
};
return
_runWriter
;
}
catch
{
return
null
;
}
}
private
static
StreamWriter
GetMetricWriter
()
{
try
{
Directory
.
CreateDirectory
(
LogsRoot
);
if
(
_metricWriter
!=
null
&&
File
.
Exists
(
_metricPath
)
&&
new
FileInfo
(
_metricPath
).
Length
<
MaxFileBytes
)
return
_metricWriter
;
if
(
_metricWriter
!=
null
)
{
try
{
_metricWriter
.
Flush
();
_metricWriter
.
Dispose
();
}
catch
{
}
_metricWriter
=
null
;
}
_metricPath
=
NextAvailable
(
"metrics_"
+
DateTime
.
Now
.
ToString
(
"yyyyMMdd"
)
+
".jsonl"
);
var
fs
=
new
FileStream
(
_metricPath
,
FileMode
.
Append
,
FileAccess
.
Write
,
FileShare
.
ReadWrite
);
_metricWriter
=
new
StreamWriter
(
fs
,
new
UTF8Encoding
(
false
))
{
AutoFlush
=
true
};
return
_metricWriter
;
}
catch
{
return
null
;
}
}
private
static
void
CloseRunWriter
()
{
if
(
_runWriter
==
null
)
return
;
try
{
_runWriter
.
Flush
();
_runWriter
.
Dispose
();
}
catch
{
}
_runWriter
=
null
;
}
/// <summary>超过阈值时加 _N 后缀轮转。</summary>
private
static
string
NextAvailable
(
string
fileName
)
{
string
p
=
Path
.
Combine
(
LogsRoot
,
fileName
);
if
(!
File
.
Exists
(
p
)
||
new
FileInfo
(
p
).
Length
<
MaxFileBytes
)
return
p
;
string
stem
=
Path
.
GetFileNameWithoutExtension
(
fileName
);
string
ext
=
Path
.
GetExtension
(
fileName
);
for
(
int
i
=
1
;
i
<
100
;
i
++)
{
string
c
=
Path
.
Combine
(
LogsRoot
,
stem
+
"_"
+
i
+
ext
);
if
(!
File
.
Exists
(
c
)
||
new
FileInfo
(
c
).
Length
<
MaxFileBytes
)
return
c
;
}
return
p
;
}
/// <summary>一轮运行入队(满则丢弃并计数)。</summary>
private
static
void
EnqueueRun
(
RunScope
s
)
{
try
{
if
(
s
==
null
||
!
_enabled
)
return
;
EnsureWriter
();
// 兜底:即使 Configure 未被宿主调用/调用晚,首次产生数据时也启动写线程,避免队列只进不出
string
line
=
JsonConvert
.
SerializeObject
(
s
);
if
(
RunQueue
.
Count
>=
QueueCapacity
)
{
Count
(
"agent_telemetry_dropped"
);
return
;
}
RunQueue
.
Enqueue
(
line
);
_runsSinceFlush
++;
if
(
_runsSinceFlush
>=
20
||
(
DateTime
.
UtcNow
-
_lastMetricFlush
).
TotalMinutes
>=
5
)
FlushMetrics
();
}
catch
{
}
}
/// <summary>落一条指标快照(计数器 + 直方图读数)。</summary>
public
static
void
FlushMetrics
()
{
try
{
_lastMetricFlush
=
DateTime
.
UtcNow
;
_runsSinceFlush
=
0
;
var
snap
=
new
Dictionary
<
string
,
object
>
{
[
"ts"
]
=
DateTime
.
UtcNow
.
ToString
(
"o"
),
[
"counters"
]
=
new
Dictionary
<
string
,
long
>(
Counters
),
[
"run_ms"
]
=
Histogram
(
"agent_run_duration_ms"
),
[
"ttfb_ms"
]
=
Histogram
(
"agent_ttfb_ms"
),
};
string
line
=
JsonConvert
.
SerializeObject
(
snap
);
var
w
=
GetMetricWriter
();
if
(
w
!=
null
)
{
w
.
Write
(
line
);
w
.
Write
(
'\n'
);
w
.
Flush
();
}
}
catch
{
}
}
/// <summary>把队列与缓冲落盘(进程退出 / 手动调用)。</summary>
public
static
void
Flush
()
{
try
{
string
line
;
while
(
RunQueue
.
TryDequeue
(
out
line
))
{
var
w
=
GetRunWriter
();
if
(
w
!=
null
)
{
w
.
Write
(
line
);
w
.
Write
(
'\n'
);
}
}
if
(
_runWriter
!=
null
)
_runWriter
.
Flush
();
if
(
_metricWriter
!=
null
)
_metricWriter
.
Flush
();
}
catch
{
}
}
/// <summary>停止写线程并落盘(宿主释放 / 关闭观测时调用;进程退出另有兜底)。</summary>
public
static
void
Shutdown
()
{
try
{
_writerStop
=
true
;
Flush
();
CloseRunWriter
();
if
(
_metricWriter
!=
null
)
{
try
{
_metricWriter
.
Flush
();
_metricWriter
.
Dispose
();
}
catch
{
}
_metricWriter
=
null
;
}
}
catch
{
}
}
/// <summary>一轮运行完成(实时事件)。</summary>
public
static
event
Action
<
RunScope
>
OnRunCompleted
;
/// <summary>一次工具调用完成(实时事件,替代 <c>OnToolStatus</c> 的字符串)。</summary>
public
static
event
Action
<
ToolSpan
>
OnToolSpan
;
}
}
Tools.Agent/Support/StorageCleanup.cs
View file @
5b9144c4
...
...
@@ -14,6 +14,9 @@ namespace Tools.MyAgent
/// <summary>日志类文件保留天数(agent 日志 / search_misses / db 审计 / tool_results)</summary>
public
const
int
LogKeepDays
=
30
;
/// <summary>可观测性 runs_*.jsonl 保留天数(比纯日志短:jsonl 更占空间)</summary>
public
const
int
RunsKeepDays
=
14
;
/// <summary>中间产物保留天数(kb_view_image 扫描件图片,可再生)</summary>
public
const
int
ImageKeepDays
=
7
;
...
...
@@ -28,7 +31,8 @@ namespace Tools.MyAgent
/// </summary>
/// <param name="agentRoot">默认 Agent 目录(output 位于其下);日志目录按程序基目录 Logs 计算</param>
/// <param name="logsRoot">日志根目录(默认 程序基目录/Logs;测试可注入临时目录)</param>
public
static
void
Run
(
string
agentRoot
,
string
logsRoot
=
null
)
/// <param name="runsKeepDays">可观测性 runs_*.jsonl 的保留天数(默认 <see cref="RunsKeepDays"/>)</param>
public
static
void
Run
(
string
agentRoot
,
string
logsRoot
=
null
,
int
runsKeepDays
=
RunsKeepDays
)
{
if
(
logsRoot
==
null
)
logsRoot
=
Path
.
Combine
(
AppDomain
.
CurrentDomain
.
BaseDirectory
,
"Logs"
);
...
...
@@ -43,6 +47,8 @@ namespace Tools.MyAgent
{
removed
+=
CleanDir
(
logsRoot
,
"agent_*.log"
,
LogKeepDays
,
now
);
removed
+=
CleanDir
(
logsRoot
,
"search_misses.csv"
,
LogKeepDays
,
now
);
removed
+=
CleanDir
(
logsRoot
,
"runs_*.jsonl"
,
runsKeepDays
,
now
);
removed
+=
CleanDir
(
logsRoot
,
"metrics_*.jsonl"
,
LogKeepDays
,
now
);
removed
+=
CleanDir
(
logsRoot
,
"db_query_audit_*.csv"
,
LogKeepDays
,
now
);
// 2. 工具结果落盘(超长工具结果溯源文件)
string
toolResults
=
Path
.
Combine
(
logsRoot
,
"tool_results"
);
...
...
Tools.Agent/Support/TelemetryStore.cs
0 → 100644
View file @
5b9144c4
using
Newtonsoft.Json.Linq
;
using
System
;
using
System.Collections.Generic
;
using
System.Globalization
;
using
System.IO
;
namespace
Tools.MyAgent
{
/// <summary>
/// 可观测性只读聚合层(Admin 看板数据源)。与写侧 <see cref="AgentTelemetry"/> 分离:
/// 写侧不因看板需求膨胀,看板想怎么查是看板的事。
/// <para>护栏:只读最近 N 天 / 单文件最多 N 行 / 坏行跳过并计数 / 全程不允许抛异常。</para>
/// </summary>
public
sealed
class
TelemetryStore
{
/// <summary>一行运行记录(看板表格的行模型)。</summary>
public
sealed
class
RunRow
{
public
string
RunId
=
""
;
public
DateTime
StartedAt
;
public
string
Mode
=
""
;
public
string
Outcome
=
""
;
public
string
ErrorType
=
""
;
public
long
TotalMs
;
public
int
ToolCalls
;
public
int
LlmCalls
;
public
int
PromptTokens
;
public
int
CompletionTokens
;
public
double
Cost
;
public
string
Model
=
""
;
/// <summary>工具 span 明细(下钻用;受 <see cref="TelemetryStore.AllowDetailed"/> 约束)。</summary>
public
List
<
ToolSpanView
>
Tools
=
new
List
<
ToolSpanView
>();
}
/// <summary>一条工具 span(下钻展示)。</summary>
public
sealed
class
ToolSpanView
{
public
string
Name
=
""
;
public
string
ArgsSummary
=
""
;
public
long
DurationMs
;
public
string
Outcome
=
""
;
public
bool
Truncated
;
}
/// <summary>按工具名聚合。</summary>
public
sealed
class
ToolAgg
{
public
string
Name
=
""
;
public
int
Calls
;
public
int
Errors
;
public
int
Timeouts
;
public
int
Truncated
;
public
long
P50
;
public
long
P95
;
public
long
Max
;
public
double
FailRate
=>
Calls
==
0
?
0
:
(
Errors
+
Timeouts
)
*
100.0
/
Calls
;
}
/// <summary>按天聚合。</summary>
public
sealed
class
DailyAgg
{
public
string
Date
=
""
;
public
int
Runs
;
public
int
Success
;
public
int
Errors
;
public
long
Tokens
;
public
double
Cost
;
public
long
P95
;
}
/// <summary>召回通道分布(直答率来源)。</summary>
public
sealed
class
ModeAgg
{
public
int
Kb
,
Mixed
,
Model
,
ToolLoop
;
public
int
Total
=>
Kb
+
Mixed
+
Model
+
ToolLoop
;
public
double
DirectRate
=>
Total
==
0
?
0
:
(
Kb
+
Mixed
)
*
100.0
/
Total
;
}
private
readonly
string
_logsRoot
;
private
readonly
int
_days
;
private
readonly
int
_maxRuns
;
/// <summary>是否允许展示详细字段(参数摘要等)。由宿主按角色判定后注入。</summary>
public
bool
AllowDetailed
{
get
;
set
;
}
/// <summary>坏行计数(>5% 时看板顶部提示日志疑似损坏)。</summary>
public
int
BadLines
{
get
;
private
set
;
}
/// <summary>已读取行数。</summary>
public
int
ReadLines
{
get
;
private
set
;
}
public
TelemetryStore
(
string
logsRoot
=
null
,
int
days
=
7
,
int
maxRuns
=
5000
)
{
_days
=
days
>
0
&&
days
<=
30
?
days
:
7
;
_maxRuns
=
maxRuns
>
0
&&
maxRuns
<=
50000
?
maxRuns
:
5000
;
try
{
_logsRoot
=
string
.
IsNullOrEmpty
(
logsRoot
)
?
Path
.
Combine
(
AppDomain
.
CurrentDomain
.
BaseDirectory
,
"Logs"
)
:
logsRoot
;
}
catch
{
_logsRoot
=
"Logs"
;
}
}
public
string
LogsRoot
=>
_logsRoot
;
/// <summary>读取最近 N 天的运行记录(倒序:最新在前)。</summary>
public
RunRow
[]
LoadRuns
()
{
var
rows
=
new
List
<
RunRow
>();
BadLines
=
0
;
ReadLines
=
0
;
try
{
foreach
(
var
file
in
EnumerateRunFiles
())
{
foreach
(
var
line
in
ReadTailLines
(
file
,
_maxRuns
))
{
ReadLines
++;
var
row
=
Parse
(
line
);
if
(
row
!=
null
)
rows
.
Add
(
row
);
else
BadLines
++;
}
}
rows
.
Sort
((
a
,
b
)
=>
b
.
StartedAt
.
CompareTo
(
a
.
StartedAt
));
if
(
rows
.
Count
>
_maxRuns
)
rows
.
RemoveRange
(
_maxRuns
,
rows
.
Count
-
_maxRuns
);
}
catch
{
}
return
rows
.
ToArray
();
}
private
IEnumerable
<
string
>
EnumerateRunFiles
()
{
var
list
=
new
List
<
string
>();
try
{
if
(!
Directory
.
Exists
(
_logsRoot
))
return
list
;
var
today
=
DateTime
.
Now
.
Date
;
for
(
int
d
=
0
;
d
<
_days
;
d
++)
{
string
name
=
"runs_"
+
today
.
AddDays
(-
d
).
ToString
(
"yyyyMMdd"
)
+
".jsonl"
;
for
(
int
n
=
0
;
n
<
20
;
n
++)
{
string
p
=
Path
.
Combine
(
_logsRoot
,
n
==
0
?
name
:
name
.
Replace
(
".jsonl"
,
"_"
+
n
+
".jsonl"
));
if
(
File
.
Exists
(
p
))
list
.
Add
(
p
);
else
if
(
n
>
0
)
break
;
}
}
}
catch
{
}
return
list
;
}
/// <summary>读文件末尾 N 行(环形缓冲,避免大文件整体入内存)。</summary>
private
static
IEnumerable
<
string
>
ReadTailLines
(
string
path
,
int
max
)
{
var
buf
=
new
Queue
<
string
>();
try
{
using
(
var
fs
=
new
FileStream
(
path
,
FileMode
.
Open
,
FileAccess
.
Read
,
FileShare
.
ReadWrite
))
using
(
var
sr
=
new
StreamReader
(
fs
))
{
string
line
;
while
((
line
=
sr
.
ReadLine
())
!=
null
)
{
if
(
buf
.
Count
>=
max
)
buf
.
Dequeue
();
buf
.
Enqueue
(
line
);
}
}
}
catch
{
}
return
buf
;
}
private
RunRow
Parse
(
string
line
)
{
try
{
if
(
string
.
IsNullOrWhiteSpace
(
line
))
return
null
;
var
o
=
JObject
.
Parse
(
line
);
var
r
=
new
RunRow
{
RunId
=
Str
(
o
,
"RunId"
),
Mode
=
Str
(
o
,
"Mode"
),
Outcome
=
Str
(
o
,
"Outcome"
),
ErrorType
=
Str
(
o
,
"ErrorType"
),
Model
=
Str
(
o
,
"ServedModel"
),
TotalMs
=
Long
(
o
,
"TotalMs"
),
ToolCalls
=
Int
(
o
,
"ToolCalls"
),
LlmCalls
=
Int
(
o
,
"LlmCalls"
),
PromptTokens
=
Int
(
o
,
"PromptTokens"
),
CompletionTokens
=
Int
(
o
,
"CompletionTokens"
),
Cost
=
Dbl
(
o
,
"Cost"
),
};
DateTime
dt
;
if
(
DateTime
.
TryParse
(
Str
(
o
,
"StartedAt"
),
CultureInfo
.
InvariantCulture
,
DateTimeStyles
.
RoundtripKind
,
out
dt
))
r
.
StartedAt
=
dt
.
ToLocalTime
();
var
arr
=
o
[
"Tools"
]
as
JArray
;
if
(
arr
!=
null
)
{
foreach
(
var
t
in
arr
)
{
var
to
=
t
as
JObject
;
if
(
to
==
null
)
continue
;
// 权限闸门:非 detailed(或角色未命中)时剔除参数摘要
string
args
=
AllowDetailed
?
Str
(
to
,
"ArgsSummary"
)
:
""
;
r
.
Tools
.
Add
(
new
ToolSpanView
{
Name
=
Str
(
to
,
"Name"
),
ArgsSummary
=
args
,
DurationMs
=
Long
(
to
,
"DurationMs"
),
Outcome
=
Str
(
to
,
"Outcome"
),
Truncated
=
Bool
(
to
,
"Truncated"
),
});
}
}
return
r
;
}
catch
{
return
null
;
}
}
/// <summary>按工具名聚合(调用数 / 失败率 / P50 / P95 / 超时 / 截断)。</summary>
public
ToolAgg
[]
AggregateTools
(
RunRow
[]
rows
)
{
var
map
=
new
Dictionary
<
string
,
ToolAgg
>(
StringComparer
.
OrdinalIgnoreCase
);
var
times
=
new
Dictionary
<
string
,
List
<
long
>>(
StringComparer
.
OrdinalIgnoreCase
);
try
{
if
(
rows
==
null
)
return
new
ToolAgg
[
0
];
foreach
(
var
r
in
rows
)
{
foreach
(
var
t
in
r
.
Tools
)
{
ToolAgg
a
;
if
(!
map
.
TryGetValue
(
t
.
Name
,
out
a
))
{
a
=
new
ToolAgg
{
Name
=
t
.
Name
};
map
[
t
.
Name
]
=
a
;
times
[
t
.
Name
]
=
new
List
<
long
>();
}
a
.
Calls
++;
if
(
t
.
Outcome
==
"error"
)
a
.
Errors
++;
if
(
t
.
Outcome
==
"timeout"
||
t
.
Outcome
==
"cancel"
)
a
.
Timeouts
++;
if
(
t
.
Truncated
)
a
.
Truncated
++;
times
[
t
.
Name
].
Add
(
t
.
DurationMs
);
}
}
foreach
(
var
kv
in
map
)
{
var
ts
=
times
[
kv
.
Key
];
ts
.
Sort
();
kv
.
Value
.
P50
=
Quantile
(
ts
,
0.50
);
kv
.
Value
.
P95
=
Quantile
(
ts
,
0.95
);
kv
.
Value
.
Max
=
ts
.
Count
>
0
?
ts
[
ts
.
Count
-
1
]
:
0
;
}
var
list
=
new
List
<
ToolAgg
>(
map
.
Values
);
list
.
Sort
((
x
,
y
)
=>
y
.
P95
.
CompareTo
(
x
.
P95
));
return
list
.
ToArray
();
}
catch
{
return
new
ToolAgg
[
0
];
}
}
/// <summary>按天聚合。</summary>
public
DailyAgg
[]
AggregateDaily
(
RunRow
[]
rows
,
int
days
)
{
var
map
=
new
Dictionary
<
string
,
DailyAgg
>(
StringComparer
.
OrdinalIgnoreCase
);
var
times
=
new
Dictionary
<
string
,
List
<
long
>>(
StringComparer
.
OrdinalIgnoreCase
);
try
{
if
(
rows
==
null
)
return
new
DailyAgg
[
0
];
foreach
(
var
r
in
rows
)
{
string
key
=
r
.
StartedAt
==
DateTime
.
MinValue
?
"未知"
:
r
.
StartedAt
.
ToString
(
"MM-dd"
);
DailyAgg
d
;
if
(!
map
.
TryGetValue
(
key
,
out
d
))
{
d
=
new
DailyAgg
{
Date
=
key
};
map
[
key
]
=
d
;
times
[
key
]
=
new
List
<
long
>();
}
d
.
Runs
++;
if
(
r
.
Outcome
==
"success"
)
d
.
Success
++;
if
(
r
.
Outcome
==
"error"
)
d
.
Errors
++;
d
.
Tokens
+=
r
.
PromptTokens
+
r
.
CompletionTokens
;
d
.
Cost
+=
r
.
Cost
;
times
[
key
].
Add
(
r
.
TotalMs
);
}
foreach
(
var
kv
in
map
)
{
var
ts
=
times
[
kv
.
Key
];
ts
.
Sort
();
kv
.
Value
.
P95
=
Quantile
(
ts
,
0.95
);
}
var
list
=
new
List
<
DailyAgg
>(
map
.
Values
);
list
.
Sort
((
a
,
b
)
=>
string
.
Compare
(
a
.
Date
,
b
.
Date
,
StringComparison
.
Ordinal
));
return
list
.
ToArray
();
}
catch
{
return
new
DailyAgg
[
0
];
}
}
/// <summary>召回通道分布。</summary>
public
ModeAgg
AggregateModes
(
RunRow
[]
rows
)
{
var
m
=
new
ModeAgg
();
try
{
if
(
rows
==
null
)
return
m
;
foreach
(
var
r
in
rows
)
{
if
(
r
.
Mode
==
"kb"
)
m
.
Kb
++;
else
if
(
r
.
Mode
==
"mixed"
)
m
.
Mixed
++;
else
if
(
r
.
Mode
==
"model"
)
m
.
Model
++;
else
m
.
ToolLoop
++;
}
}
catch
{
}
return
m
;
}
/// <summary>错误类型分布(Top N)。</summary>
public
KeyValuePair
<
string
,
int
>[]
AggregateErrors
(
RunRow
[]
rows
,
int
top
)
{
var
map
=
new
Dictionary
<
string
,
int
>(
StringComparer
.
OrdinalIgnoreCase
);
try
{
if
(
rows
==
null
)
return
new
KeyValuePair
<
string
,
int
>[
0
];
foreach
(
var
r
in
rows
)
{
if
(
r
.
Outcome
!=
"error"
)
continue
;
string
k
=
string
.
IsNullOrEmpty
(
r
.
ErrorType
)
?
"unknown"
:
r
.
ErrorType
;
map
[
k
]
=
map
.
TryGetValue
(
k
,
out
var
v
)
?
v
+
1
:
1
;
}
var
list
=
new
List
<
KeyValuePair
<
string
,
int
>>(
map
);
list
.
Sort
((
a
,
b
)
=>
b
.
Value
.
CompareTo
(
a
.
Value
));
if
(
list
.
Count
>
top
)
list
.
RemoveRange
(
top
,
list
.
Count
-
top
);
return
list
.
ToArray
();
}
catch
{
return
new
KeyValuePair
<
string
,
int
>[
0
];
}
}
/// <summary>最近 N 条错误(看板 ⑤Tab)。</summary>
public
RunRow
[]
RecentErrors
(
RunRow
[]
rows
,
int
count
)
{
var
list
=
new
List
<
RunRow
>();
try
{
if
(
rows
==
null
)
return
new
RunRow
[
0
];
foreach
(
var
r
in
rows
)
{
if
(
r
.
Outcome
==
"error"
||
r
.
Outcome
==
"cancel"
)
list
.
Add
(
r
);
if
(
list
.
Count
>=
count
)
break
;
}
}
catch
{
}
return
list
.
ToArray
();
}
/// <summary>24 小时分布:按小时统计成功/失败(概览柱状图)。</summary>
public
long
[]
Hourly
(
RunRow
[]
rows
,
out
long
[]
failed
)
{
var
ok
=
new
long
[
24
];
var
bad
=
new
long
[
24
];
failed
=
bad
;
try
{
if
(
rows
==
null
)
return
ok
;
var
since
=
DateTime
.
Now
.
AddHours
(-
24
);
foreach
(
var
r
in
rows
)
{
if
(
r
.
StartedAt
==
DateTime
.
MinValue
||
r
.
StartedAt
<
since
)
continue
;
int
h
=
r
.
StartedAt
.
Hour
;
if
(
r
.
Outcome
==
"success"
)
ok
[
h
]++;
else
bad
[
h
]++;
}
}
catch
{
}
return
ok
;
}
private
static
long
Quantile
(
List
<
long
>
sorted
,
double
q
)
{
if
(
sorted
.
Count
==
0
)
return
0
;
int
idx
=
(
int
)
Math
.
Ceiling
(
sorted
.
Count
*
q
)
-
1
;
if
(
idx
<
0
)
idx
=
0
;
if
(
idx
>=
sorted
.
Count
)
idx
=
sorted
.
Count
-
1
;
return
sorted
[
idx
];
}
private
static
string
Str
(
JObject
o
,
string
name
)
{
var
v
=
o
[
name
];
return
v
==
null
?
""
:
(
v
.
Type
==
JTokenType
.
Null
?
""
:
v
.
ToString
());
}
private
static
int
Int
(
JObject
o
,
string
name
)
{
try
{
return
o
[
name
]
==
null
?
0
:
(
int
)
o
[
name
];
}
catch
{
return
0
;
}
}
private
static
long
Long
(
JObject
o
,
string
name
)
{
try
{
return
o
[
name
]
==
null
?
0
:
(
long
)
o
[
name
];
}
catch
{
return
0
;
}
}
private
static
double
Dbl
(
JObject
o
,
string
name
)
{
try
{
return
o
[
name
]
==
null
?
0
:
(
double
)
o
[
name
];
}
catch
{
return
0
;
}
}
private
static
bool
Bool
(
JObject
o
,
string
name
)
{
try
{
return
o
[
name
]
!=
null
&&
(
bool
)
o
[
name
];
}
catch
{
return
false
;
}
}
}
}
Tools.Agent/Tools.Agent.csproj
View file @
5b9144c4
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8"?>
<Project
ToolsVersion=
"15.0"
xmlns=
"http://schemas.microsoft.com/developer/msbuild/2003"
>
<Import
Project=
"$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props"
Condition=
"Exists('$(MSBuildExtensionsPath)\$(MSBuildToolsVersion)\Microsoft.Common.props')"
/>
<PropertyGroup>
...
...
@@ -122,6 +122,12 @@
<Compile
Include=
"Config\AgentConfig.cs"
/>
<Compile
Include=
"Config\RoleTemplateStore.cs"
/>
<Compile
Include=
"Config\SecretProtector.cs"
/>
<Compile
Include=
"Forms\AgentAdminForm.cs"
>
<SubType>
Form
</SubType>
</Compile>
<Compile
Include=
"Forms\DashChart.cs"
>
<SubType>
Component
</SubType>
</Compile>
<Compile
Include=
"Forms\AgentHistoryForm.cs"
>
<SubType>
Form
</SubType>
</Compile>
...
...
@@ -153,6 +159,9 @@
<Compile
Include=
"Kb\SynonymIndex.cs"
/>
<Compile
Include=
"ShareHelper.cs"
/>
<Compile
Include=
"Support\AgentLogger.cs"
/>
<Compile
Include=
"Support\AgentTelemetry.cs"
/>
<Compile
Include=
"Support\AgentHealth.cs"
/>
<Compile
Include=
"Support\TelemetryStore.cs"
/>
<Compile
Include=
"Support\AgentUtils.cs"
/>
<Compile
Include=
"Support\BackupHelper.cs"
/>
<Compile
Include=
"Support\ChatMessage.cs"
/>
...
...
Tools.Agent/wMyAgent.cs
View file @
5b9144c4
...
...
@@ -379,6 +379,16 @@ namespace Tools.MyAgent
CustomPrices
=
cfg
.
CustomPrices
??
new
Dictionary
<
string
,
string
>(),
DbConnections
=
protectedConns
,
ToolRoleAllow
=
cfg
.
ToolRoleAllow
??
new
Dictionary
<
string
,
string
>(),
// 可观测性 + Admin 看板(漏登记 ⇒ 设置页保存一次即被静默清空)
TelemetryEnabled
=
cfg
.
TelemetryEnabled
,
ObservabilityLevel
=
cfg
.
ObservabilityLevel
,
HealthCheckOnStart
=
cfg
.
HealthCheckOnStart
,
RunsKeepDays
=
cfg
.
RunsKeepDays
,
ShowRunSummary
=
cfg
.
ShowRunSummary
,
AdminEnabled
=
cfg
.
AdminEnabled
,
AdminRoles
=
cfg
.
AdminRoles
,
DashboardDays
=
cfg
.
DashboardDays
,
AdminAutoRefresh
=
cfg
.
AdminAutoRefresh
,
};
File
.
WriteAllText
(
AgentConfigFilePath
,
Newtonsoft
.
Json
.
JsonConvert
.
SerializeObject
(
toSave
),
System
.
Text
.
Encoding
.
UTF8
);
}
...
...
@@ -448,7 +458,7 @@ namespace Tools.MyAgent
try
{
Directory
.
CreateDirectory
(
DefaultKbRoot
);
}
catch
{
}
_kb
.
SetOutputRoot
(
DefaultKbRoot
);
// 评估#1:存储自动清理(日志 30 天 / 中间产物 7~30 天 / 正式产出 90 天;后台执行不阻塞启动)
try
{
System
.
Threading
.
Tasks
.
Task
.
Run
(()
=>
StorageCleanup
.
Run
(
DefaultKbRoot
));
}
catch
{
}
try
{
System
.
Threading
.
Tasks
.
Task
.
Run
(()
=>
StorageCleanup
.
Run
(
DefaultKbRoot
,
null
,
_config
.
RunsKeepDays
));
}
catch
{
}
// 大知识库不在窗口初始化阶段创建 SQLite 索引,避免打开业务引用时被 IO/SQLite 锁住。
// 小知识库由统计完成事件按需后台启动,实际搜索也会触发惰性启动。
RefreshScopeCombo
();
// 先归一化搜索范围(命中下拉项/归一化为全部),再应用
...
...
@@ -510,6 +520,7 @@ namespace Tools.MyAgent
_engine
.
OnFileProduced
+=
f
=>
PostToUi
(()
=>
OnFileProduced
(
f
));
_engine
.
OnComplete
+=
()
=>
PostToUi
(
OnAssistantComplete
);
_engine
.
OnAnswerAttachments
+=
a
=>
PostToUi
(()
=>
OnAnswerAttachments
(
a
));
// P5:回答形态 + 关联图片
AgentTelemetry
.
OnRunCompleted
+=
r
=>
PostToUi
(()
=>
OnRunCompleted
(
r
));
// M4:本轮运行摘要
_engine
.
OnToolTrace
+=
trace
=>
PostToUi
(()
=>
OnToolTrace
(
trace
));
_engine
.
OnError
+=
m
=>
PostToUi
(()
=>
OnEngineError
(
m
));
_engine
.
OnStatusChanged
+=
()
=>
PostToUi
(
UpdateStatusIndicator
);
...
...
@@ -1040,9 +1051,26 @@ namespace Tools.MyAgent
string
text
=
$"本回复
{
prompt
}
+
{
completion
}
tokens"
;
double
cost
=
_engine
.
TotalCost
-
_replyCostBase
;
if
(
cost
>
0
)
text
+=
$"(约
{
CostEstimator
.
Format
(
cost
)}
)"
;
// M4:本轮摘要(耗时/工具数/通道)—— 默认关闭,避免打扰(AgentConfig.ShowRunSummary)
if
(
_config
.
ShowRunSummary
&&
_lastRunSummary
.
Length
>
0
)
text
+=
" · "
+
_lastRunSummary
;
return
text
;
}
/// <summary>最近一轮的运行摘要(订阅 <c>AgentTelemetry.OnRunCompleted</c> 取得)</summary>
private
string
_lastRunSummary
=
""
;
/// <summary>M4:一轮运行完成 → 生成摘要文本(供回复尾注使用)</summary>
private
void
OnRunCompleted
(
AgentTelemetry
.
RunScope
r
)
{
if
(
r
==
null
)
return
;
var
sb
=
new
System
.
Text
.
StringBuilder
();
sb
.
Append
(
r
.
TotalMs
).
Append
(
"ms"
);
if
(
r
.
ToolCalls
>
0
)
sb
.
Append
(
" · "
).
Append
(
r
.
ToolCalls
).
Append
(
" 工具"
);
if
(!
string
.
IsNullOrEmpty
(
r
.
Mode
)
&&
r
.
Mode
!=
"tool-loop"
)
sb
.
Append
(
" · "
).
Append
(
r
.
Mode
).
Append
(
"命中"
);
if
(
r
.
FailoverCount
>
0
)
sb
.
Append
(
" · 转移"
).
Append
(
r
.
FailoverCount
).
Append
(
"次"
);
_lastRunSummary
=
sb
.
ToString
();
}
/// <summary>工具轨迹(#16):回答末尾追加可折叠 details,展示工具调用链(先宽后窄的检索过程)</summary>
private
void
OnToolTrace
(
System
.
Collections
.
Generic
.
IReadOnlyList
<
AgentEngine
.
ToolTraceEntry
>
trace
)
{
...
...
@@ -2691,6 +2719,15 @@ namespace Tools.MyAgent
case
"/glossary"
:
AppendGlossary
(
arg
);
break
;
case
"/admin"
:
OpenAdmin
();
break
;
case
"/stats"
:
RunStats
();
break
;
case
"/diag"
:
RunDiagnostics
();
break
;
case
"/help"
:
AddSystemMessage
(
BuildHelpText
());
break
;
...
...
@@ -2716,6 +2753,9 @@ namespace Tools.MyAgent
"- `/backup` - 一键备份:会话历史 + 配置(密钥脱敏) + 知识库摘要为 zip\n"
+
"- `/restore` - 从备份 zip 恢复会话历史与配置(密钥需重新填写)\n"
+
"- `/glossary 术语=标准名` - 向知识库术语表(kb-config.json)沉淀一条术语,提升召回\n"
+
"- `/stats` - 本次进程用量统计(轮数/P50·P95/费用)\n"
+
"- `/admin` - 打开 Admin 看板(5 个 Tab,可导出 CSV/Markdown/HTML)\n"
+
"- `/diag` - 依赖健康检查(9 项)+ 本次进程用量\n"
+
"- `/settings` - 打开设置(高级项在「高级设置」中)\n"
+
"- `/model <模型名>` / `/scope <目录>` - 快速切换模型 / 搜索范围\n"
+
"- `/workflow` - 知识库周报(可选能力,需在高级设置启用)\n"
+
...
...
@@ -2752,6 +2792,100 @@ namespace Tools.MyAgent
AddSystemMessage
(
$"**知识库检索:
{
keyword
}
**(
{
sw
.
ElapsedMilliseconds
}
ms)\n\n
{
result
}
"
);
}
/// <summary>
/// /admin:打开 Admin 看板。
/// <para>权限:<c>AdminRoles</c> 为空 = 不限制(保持现状,不破坏既有宿主);
/// 仅当隐私级别为 detailed 时才把参数摘要传给看板。</para>
/// </summary>
private
void
OpenAdmin
()
{
if
(!
_config
.
AdminEnabled
)
{
AddSystemMessage
(
"Admin 看板已关闭(配置项 AdminEnabled=false)。"
);
return
;
}
if
(!
IsAdminRole
())
{
AddSystemMessage
(
"当前角色「"
+
CurrentUserName
()
+
"」无权打开 Admin 看板(配置项 AdminRoles 未包含该角色)。"
);
return
;
}
try
{
int
days
=
_config
.
DashboardDays
>
0
&&
_config
.
DashboardDays
<=
30
?
_config
.
DashboardDays
:
7
;
var
form
=
new
Tools
.
MyAgent
.
Forms
.
AgentAdminForm
(
null
,
days
,
AgentTelemetry
.
IsDetailed
);
form
.
Show
(
this
);
}
catch
(
Exception
ex
)
{
AddSystemMessage
(
"打开看板失败:"
+
ex
.
Message
);
}
}
/// <summary>角色判定:与 <c>ToolRoleAllow</c> 同一套语义(当前登录用户即角色)。</summary>
private
bool
IsAdminRole
()
{
string
roles
=
_config
.
AdminRoles
??
""
;
if
(
string
.
IsNullOrWhiteSpace
(
roles
))
return
true
;
// 空 = 不限制
string
user
=
CurrentUserName
();
foreach
(
var
r
in
roles
.
Split
(
new
[]
{
','
,
';'
,
','
,
';'
},
StringSplitOptions
.
RemoveEmptyEntries
))
if
(
string
.
Equals
(
r
.
Trim
(),
user
,
StringComparison
.
OrdinalIgnoreCase
))
return
true
;
return
false
;
}
/// <summary>/stats:本次进程累计(多日趋势见 Admin 看板)</summary>
private
void
RunStats
()
{
var
st
=
AgentTelemetry
.
Snapshot
();
var
sb
=
new
System
.
Text
.
StringBuilder
();
sb
.
Append
(
"**用量统计**(本次进程)\n\n"
);
sb
.
Append
(
"- 轮数:"
).
Append
(
st
.
Runs
).
Append
(
"(成功 "
).
Append
(
st
.
Success
)
.
Append
(
" · 失败 "
).
Append
(
st
.
Errors
).
Append
(
" · 取消 "
).
Append
(
st
.
Cancels
).
Append
(
")\n"
);
if
(
st
.
Duration
.
Count
>
0
)
sb
.
Append
(
"- 耗时:P50 "
).
Append
(
st
.
Duration
.
P50
).
Append
(
"ms · P95 "
).
Append
(
st
.
Duration
.
P95
)
.
Append
(
"ms · 最大 "
).
Append
(
st
.
Duration
.
Max
).
Append
(
"ms\n"
);
if
(
st
.
Ttfb
.
Count
>
0
)
sb
.
Append
(
"- 首包:P50 "
).
Append
(
st
.
Ttfb
.
P50
).
Append
(
"ms · P95 "
).
Append
(
st
.
Ttfb
.
P95
).
Append
(
"ms\n"
);
sb
.
Append
(
"- 工具:"
).
Append
(
st
.
ToolCalls
).
Append
(
" 次 · LLM:"
).
Append
(
st
.
LlmCalls
).
Append
(
" 次\n"
);
long
tokens
=
st
.
PromptTokens
+
st
.
CompletionTokens
;
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
.
Dropped
>
0
)
sb
.
Append
(
"- ⚠ 丢弃 "
).
Append
(
st
.
Dropped
).
Append
(
" 条(采集跟不上)\n"
);
if
(
st
.
Runs
==
0
)
sb
.
Append
(
"_暂无数据(本进程尚未完成任何一轮回答)_\n"
);
AddSystemMessage
(
sb
.
ToString
());
}
/// <summary>/diag:依赖健康检查(9 项) + 本次进程累计用量</summary>
private
async
void
RunDiagnostics
()
{
var
items
=
new
System
.
Collections
.
Generic
.
List
<
HealthItem
>(
AgentHealth
.
Check
(
_config
,
_kb
,
_engine
.
DiagnosticRoots
));
// 诊断根由引擎持有(窗体未另存字段)
bool
ok
=
false
;
string
msg
=
""
;
try
{
var
r
=
await
_engine
.
TestConnectionAsync
(
System
.
Threading
.
CancellationToken
.
None
);
ok
=
r
.
Ok
;
msg
=
r
.
Message
;
}
catch
(
Exception
ex
)
{
msg
=
ex
.
Message
;
}
items
.
Insert
(
1
,
AgentHealth
.
ModelService
(
ok
,
msg
));
AgentHealth
.
Save
(
items
.
ToArray
());
AddSystemMessage
(
AgentHealth
.
RenderMarkdown
(
items
.
ToArray
())
+
"\n"
+
BuildDiagUsage
());
}
/// <summary>/diag 的用量段(进程内累计;多日趋势见 Admin 看板)</summary>
private
string
BuildDiagUsage
()
{
var
st
=
AgentTelemetry
.
Snapshot
();
string
s
=
"**本次进程累计**:"
+
st
.
Runs
+
" 轮(成功 "
+
st
.
Success
+
" · 失败 "
+
st
.
Errors
+
" · 取消 "
+
st
.
Cancels
+
")"
;
if
(
st
.
Duration
.
Count
>
0
)
s
+=
" · P50 "
+
st
.
Duration
.
P50
+
"ms / P95 "
+
st
.
Duration
.
P95
+
"ms"
;
long
tokens
=
st
.
PromptTokens
+
st
.
CompletionTokens
;
if
(
tokens
>
0
)
s
+=
" · "
+
st
.
PromptTokens
+
"+"
+
st
.
CompletionTokens
+
" tokens"
;
if
(
st
.
Cost
>
0
)
s
+=
" · "
+
CostEstimator
.
Format
(
st
.
Cost
);
if
(
st
.
Dropped
>
0
)
s
+=
" · ⚠ 丢弃 "
+
st
.
Dropped
+
" 条(采集跟不上)"
;
return
s
;
}
/// <summary>/model <name>:快速切换模型</summary>
private
void
SwitchModel
(
string
model
)
{
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment