本文是 DeepSeekPulse 的Design Docs第三篇,讲三个关键技术决定。 产品定位见《产品定位与边界》,整体架构见《系统架构与数据流》。
决定一:GLM 额度靠 unit 解码,不能靠字段名硬编码
GLM 的套餐接口一次返回三种额度,但它们的结构是"同构数组":
{
"data": {
"level": "pro",
"limits": [
{ "type": "TOKENS_LIMIT", "unit": 3, "number": 5, "percentage": 38,
"nextResetTime": 1781345721976 },
{ "type": "TOKENS_LIMIT", "unit": 6, "number": 1, "percentage": 55,
"nextResetTime": 1781928310123 },
{ "type": "TIME_LIMIT", "unit": 5, "number": 1,
"usage": 1000, "currentValue": 120, "percentage": 12,
"nextResetTime": 1783915420110 }
]
}
}
三个元素长得几乎一样,唯一的区别在 type + unit:
| type | unit | number | 含义 |
|---|---|---|---|
TOKENS_LIMIT | 3 | 5 | 5 小时 prompt 池 |
TOKENS_LIMIT | 6 | 1 | 1 周 prompt 池 |
TIME_LIMIT | 5 | 1 | MCP 工具月额度 |
unit 的取值是时间单位编号:3 = 小时,5 = 月,6 = 周。number 是数量。所以 unit=3, number=5 读作"3 这个单位 × 5",即 5 小时。
不能靠数组下标取。 三个元素不保证都返回——部分账号没有周额度窗口,那么 array 就只有两个元素,limits[1] 就从"周额度"变成了"MCP 额度"。必须按键值匹配:
if (type.Equals("TOKENS_LIMIT", StringComparison.OrdinalIgnoreCase))
{
if (unit == 3) (kind, label) = ("tokens5h", "5 小时额度");
else if (unit == 6) (kind, label) = ("tokensWeek", "1 周额度");
else (kind, label) = ("tokensOther", "额度");
}
else if (type.Equals("TIME_LIMIT", StringComparison.OrdinalIgnoreCase))
{
(kind, label) = ("tools", unit == 5 ? "MCP 工具额度(月)" : "工具额度");
}
另外一个细节:两个类型的百分比来源不同。
TOKENS_LIMIT只给percentage(已用百分比)TIME_LIMIT额外给usage(总量)和currentValue(已用),可以自己算
所以解析要分支,并且对 percentage 的数值范围留一手(不同版本可能用 0–1 小数或 0–100 整数):
long usage = ReadLong(item, "usage");
long currentValue = ReadLong(item, "currentValue");
if (usage > 0 && item.TryGetProperty("currentValue", out _)) {
usedPercent = currentValue * 100.0 / usage; // 有明细就直接算
} else {
double pct = ReadDouble(item, "percentage");
usedPercent = pct <= 1.0 && usage > 0 ? pct * 100.0 : pct;
}
附带的鉴权问题
同一个接口,两份技术文档给了相反的鉴权方式:一份说"直接放裸 Key,加 Bearer 会 401",另一份写的就是 Authorization: Bearer <key>。
没有权威答案,就不猜。做成双重探测:
private static List<bool> OrderAuth()
{
var list = new List<bool>();
if (_needBearer is bool known) list.Add(known); // 上次成功的方式优先
foreach (var b in new[] { false, true })
if (!list.Contains(b)) list.Add(b);
return list;
}
请求失败(401/403)就换另一种,成功就缓存到静态字段。后续不再重复试探,日志里会记录最终确定的方式。
这条经验可以推广:当文档打架而你有能力实测时,写十行探测代码比查半天资料划算。
决定二:刷新时间必须从接口读,一行本地推算都不要写
这是全项目最危险的一个决定,因为做错了不会报错,只会安静地显示错误数字。
GLM 的官方刷新规则是:
- 5 小时额度:从每次请求消耗的时刻起,滚动 5 小时后重置
- 周额度:按下单时间起每 7 天刷新
注意"滚动"两个字。它不是整点对齐——不是每天 9:00 / 14:00 / 19:00 重置,而是"你最后一次消耗之后推 5 小时"。
这意味着不存在一个可以用本地时钟推算的重置时刻。它依赖你的使用行为,是个动态值。
好在这个接口直接给了 nextResetTime(毫秒时间戳):
DateTime? resetAt = null;
long resetMs = ReadLong(item, "nextResetTime");
if (resetMs > 0)
resetAt = DateTimeOffset.FromUnixTimeMilliseconds(resetMs).LocalDateTime;
而且这个字段是可选的,缺失时正确做法是隐藏"X 小时后重置"这一行,而不是退回去猜:
public string ResetText
{
get
{
if (ResetAt is null) return ""; // 没有就空着
var span = ResetAt.Value - DateTime.Now;
if (span <= TimeSpan.Zero) return "即将重置";
return PeakHours.Humanize(span) + "后重置";
}
}
为什么说这个坑危险:如果一开始按"每日 9/14/19 点重置"的直觉去实现,界面上会出现一个看起来非常合理的倒计时。它每天都在变,用户不会怀疑;只有在某次刚好用超了才发现不对。这类"沉默的错误"比崩溃难查得多。
判定原则可以写成一句话:任何依赖服务端状态的数值,只要接口给了,就不要自己算。
决定三:进程残留判定,推翻两次才正确
这是全项目唯一会执行不可逆操作的功能(Kill),也是唯一经历了完整推翻重做的部分。
第一版:父链走到断点就算孤儿
从目标进程沿 th32ParentProcessID 往上走
→ 走到某个 PID 已不存在的地方 = 链断了 = 孤儿
第一次运行的结果:扫出 293 个进程,全部标记「可安全清理」,合计 18 GB。
列表里躺着 ShellExperienceHost、ctfmon、NVDisplay.Container、Feishu……全是系统组件和日常在用的软件。如果当时手快点了全选,机器基本就废了。
根因 1:.exe 后缀。 PROCESSENTRY32W.szExeFile 返回的是 explorer.exe,而白名单里写的是 explorer,HashSet.Contains 永远匹配不上。于是"正常宿主"名单形同虚设,每条链都被判成孤儿。
private static string BaseName(string name)
{
var n = (name ?? "").Trim();
int dot = n.LastIndexOf('.');
return dot > 0 ? n[..dot] : n;
}
修完之后从 293 降到 32。数字对了,但判定依然是错的。
根因 2:飞书的启动器Sign out了。 32 个里有一条:
Feishu.exe(14332) ppid=19100[已Sign out] 278.4 MB
飞书明明开着窗口在用,但它的父进程(某个启动器/更新器)确实Sign out了。
这在 Windows 上极其常见:启动器拉起主程序后自己Sign out。父进程消失只说明"当初启动它的东西没了",不说明子进程失活。
所以"父链断了就是孤儿"这条路从根子上错了。
第二版:直接父 + 活跃树
第一步:只看直接父进程。
"孤儿"的严格定义是"当它Sign out时没有任何进程会收到通知",这只跟直接父进程有关,跟祖父无关。
bool parentAlive = snapshot.ContainsKey(raw.ParentPid);
第二步:活跃树。
关键洞察:判断"有没有用"不该看父链,而该看它是否还连着某个有用户界面的东西。
以所有有可见窗口的进程为种子,向外扩张两圈——往上整条祖先链(谁拉起它的),往下整棵子孙树(它拉起了谁):
foreach (int seed in seeds) // seeds = 有 MainWindowHandle 的进程
{
// 往上的祖先链
foreach (var anc in WalkUp(snapshot, seed, 24)) active.Add(anc.Pid);
// 往下的子孙树
var queue = new Queue<int>();
queue.Enqueue(seed);
while (queue.Count > 0)
{
int cur = queue.Dequeue();
if (!active.Add(cur)) continue;
if (children.TryGetValue(cur, out var kids))
foreach (int kid in kids) queue.Enqueue(kid);
}
}
这一步刚好解决了飞书问题:飞书主进程没有窗口,但它的渲染子进程有窗口。以渲染进程为种子往上走,整条祖先链就把主进程包含进来了。
最终判据收敛成一句:
直接父进程已不存在 且 不属于任何「有窗口进程的祖先链或子孙树」 → 才算残留。
两个实现层的坑
坑 1:杀一棵树,不是杀一个进程。
Process.Kill(entireProcessTree: true) 会连带整棵子树。这意味着统计不能只看进程自己:
node.exe(22356) 自身 46.8 MB ← 看起来不值得清理
├ python.exe(13016) 938.1 MB ← 真正的大头在这里
├ node.exe(21740) 92.5 MB
├ node.exe(5168) 55.9 MB
└ conhost × 若干 各 10 MB
合计 1.17 GB
一个 47 MB 的进程背后挂着 1.17 GB。界面上只显示 47 MB 的话,用户会直接跳过它,永远清不掉真正吃内存的东西。
所以每一项都要算 子树进程数 + 子树总内存,确认框里把子进程逐个列出来。
坑 2:保护逻辑要做两层。
UI 层把受保护的行灰掉、用 ItemCheck 阻止勾选——但这只是第一层。执行层必须再判一次:
foreach (var item in items)
{
if (item.IsProtected)
{
errors.Add($"{item.Name}({item.Pid}) 属于运行中的程序,已跳过");
continue;
}
...
}
因为 UI 可以被绕过(批量勾选、代码调用、命令行参数),而 Kill 不可逆。
实测验证:拿一个 1.46 GB 的进程去调精确清理接口,日志输出
[WARN] [kill] PID 14388 属于运行中的程序(WorkBuddy 进程(受保护)),拒绝结束
进程原地存活。
这个方案的天花板
必须写清楚它解决不了什么:
纯Admin、无窗口、父进程又Sign out的服务型进程,会被判成孤儿。 比如一个常驻同步工具,启动器Sign out且自己没有可见窗口,就会被列进"可清理",即使它还在正常工作。
没有可靠的通用信号区分"安静地服务"和"安静地僵死"。CPU 时间是否增长、有没有网络连接、有没有文件句柄活动——都不够准,成本也不对。
所以最终交付的形态是扫描 + 勾选 + 二次确认,而不是一个"一键优化"按钮。命令行的 --cleanup-auto 也建议先跑 --dry-run 看一眼。
不可逆的操作,多一点摩擦是对的。