第一次意识到自己需要一个 Token 用量面板,是在连续调试了三天 DeepSeek API 之后。控制台里的用量统计不是不好,但它和我的实际工作流隔了一层:要么切浏览器,要么等页面加载,更重要的是多账号、多脚本混在一起,根本分不清到底哪个项目在偷偷吃 token。于是我想,能不能在任务栏旁边放一个小窗口,一抬眼就知道今天跑了多少 token、余额还剩多少。在 Windows 上用 PowerShell + WinForms 做这件事,比想象中顺手得多,一个下午就能从零搭出一个能用的桌面面板。
这篇文章我会按自己的真实开发顺序来写:先讲为什么选 PowerShell 而不是 Python 或 C#,再讲 DeepSeek API 调用里最容易被忽略的 Token 计数细节,然后是 WinForms 界面的搭建、配置安全存储,最后附上跑了一周的实测数据和踩坑记录。适合已经能调通 DeepSeek API、但对桌面程序没太多经验的开发者参考,照着代码改一改就能用。
1. 为什么一部 PowerShell 脚本也能成为日常 API 用量仪表盘
1.1 我到底缺一个什么样的工具
先说清楚需求边界。我要的不是一个复杂的运维平台,而是三个具体能力:第一,打开就能看到当前账号的总余额和已用 token 总量;第二,能看到最近几次请求分别消耗了多少 token,按模型区分;第三,程序可以挂在后台,开机自启,最小化到托盘不碍事。
这个需求听起来简单,但市面上现成的工具大多对不上。几家 API 聚合平台自带控制台,可那是网页,还得登录;一些第三方监控工具功能很全,但要安装运行时、要配置数据库,为了看几个数字搞这么重没必要。我要的是一个轻量的本地小工具,最好双击就能跑,不依赖外部环境。
所以这个面板本质上是“API 客户端 + 本地统计 + 桌面外壳”三件事的组合。API 客户端负责调 DeepSeek 的余额接口和聊天接口,本地统计负责把每次请求返回的 usage 数据累加存起来,桌面外壳负责把数字用友好方式展示出来。这三点恰好都是 PowerShell 的舒适区。
1.2 选型对比:为什么是 PowerShell 而不是 Python / C#
我评估过三条技术路线。Python + Tkinter 或者 PyQt 当然能做,功能上限高,但代价是目标机器得装 Python 环境,如果哪天换电脑或者丢给同事用,还得先配虚拟环境。C# WinForms 是最正统的 Windows 桌面方案,可为了一个一百行能搞定的小工具去开 Visual Studio 项目,工程上有点杀鸡用牛刀。
PowerShell + WinForms 的优势在于:PowerShell 5.1 在 Win10/Win11 上开箱即用,完全不需要额外运行时;WinForms 是 .NET 的类库,PowerShell 可以直接 Add-Type 加载;调用 DeepSeek API 本质上就是 REST 调用,Invoke-RestMethod 一个 cmdlet 就完了。这意味着整个项目可以是一份单独的 .ps1 文件,拷贝到哪儿都能跑。
代价也有,最明显的是 GUI 开发体验比 C# 原始不少:没有可视化设计器,控件只能一个个 New-Object 出来手动摆;事件处理用脚本块,调试起来不如 IDE 方便。但这些代价在“小工具”这个量级下完全可接受,我实际做下来,从零到能用的版本只花了两个多小时。
1.3 文件结构和整体流程
最终的项目结构非常简单:
DeepSeekTokenPanel/ ├─ DeepSeekTokenPanel.ps1 # 主程序,包含界面和逻辑 └─ config.json # 第一次运行后自动生成config.json 放在 $env:APPDATA\DeepSeekTokenPanel\ 下,不放在脚本目录,是为了避免把加密后的 API Key 跟着脚本一起被拷贝走。主程序内部按“初始化配置 -> 构建窗体 -> 启动定时器 -> 进入消息循环”的顺序执行,其中初始化配置和构建窗体之间会插入一次异步的首次数据加载。
整体流程一句话概括:窗体启动后,定时器每隔 5 分钟调用一次 DeepSeek 的余额接口和一次本地统计汇总,结果刷新到界面上;每次你用自己的业务脚本调用聊天接口时,把返回的 usage 追加到本地 JSON 里,面板就能看到累计趋势。
2. DeepSeek API 调用与 Token 计数的正确打开方式
2.1 鉴权、请求构造与最小可调用代码
DeepSeek 的 API 兼容 OpenAI 的调用格式,所以如果你之前调过其他同类接口,迁移起来几乎没有成本。核心是两点:接口地址用https://api.deepseek.com,请求头里带Authorization: Bearer <你的API Key>。
最基础的鉴权验证可以用一行命令完成:
$headers = @{ "Authorization" = "Bearer $apiKey" "Content-Type" = "application/json" } # 查询余额 $balance = Invoke-RestMethod -Uri "https://api.deepseek.com/user/balance" ` -Headers $headers -Method Get $balance.balance_infos需要说明的是,$apiKey必须从安全存储里读出来,不能硬编码在脚本里。至于怎么安全存,第四章会专门讲。我一开始图省事直接写在变量里,结果一不小心把脚本传给别人时差点把 Key 带出去,后来老老实实做了加密。
模型的选取上,目前官方有两个模型名:deepseek-chat对应 DeepSeek-V3,deepseek-reasoner对应 DeepSeek-R1。前者适合通用对话,后者会先输出推理过程再给答案,所以消耗的 token 通常会更多。面板里我建议把模型名也记录下来,方便后面按模型拆分统计。
2.2 usage 字段怎么读,余额接口怎么用
这是整个项目最容易理解错的地方。调用聊天接口后,正常响应里会带一个usage对象,里面有三组数字:prompt_tokens、completion_tokens、total_tokens。它们只代表“这一次请求”消耗的量,不是账号维度的累计量。很多人第一次对接时以为拿到这个字段就等于拿到全部用量,实际上这只是单次请求的账单。
$body = @{ model = "deepseek-chat" messages = @(@{ role = "user"; content = "ping" }) stream = $false } | ConvertTo-Json -Depth 5 $resp = Invoke-RestMethod -Uri "https://api.deepseek.com/chat/completions" ` -Headers $headers -Method Post -Body $body $resp.usage响应结构大致如下:
| 字段 | 含义 |
|---|---|
| prompt_tokens | 输入内容拆成的 token 数 |
| completion_tokens | 输出内容拆成的 token 数 |
| total_tokens | 两者之和 |
| model | 实际使用的模型名 |
而余额接口返回的total_balance是账号的充值余额和赠送余额之和,单位不是 token,而是货币单位。所以面板上应该同时展示两个维度的信息:余额是“还剩多少钱”,token 用量是“消耗了多少处理量”。前者直接查官方接口,后者需要自己在本地把每次请求的 usage 累加起来。
2.3 PowerShell 5.1 里最容易翻车的错误处理细节
如果只在 PowerShell 7 里跑,错误处理很简单,Invoke-RestMethod提供了-SkipHttpErrorCheck参数,出错后还能从响应里继续读内容。但问题是很多 Windows 机器默认还是 PowerShell 5.1,这套方案在 5.1 里会遇到两个经典问题。
第一个是 TLS 版本问题。5.1 默认可能走 TLS 1.0,而现代 API 基本都要求 TLS 1.2,直接报“请求被中止: 未能创建 SSL/TLS 安全通道”。解决方法是脚本开头强制指定:
[Net.ServicePointManager]::SecurityProtocol = ` [Net.SecurityProtocolType]::Tls12第二个是错误响应体读不到。5.1 的Invoke-RestMethod在收到 401、429、500 时直接抛异常,异常信息里通常只有状态码,服务器返回的具体错误信息拿不到。排查时很难受。我封装了一个函数来处理这种情况,核心思路是抓Exception.Response的响应流:
function Invoke-DeepSeekRequest { param($Uri, $Headers, $Method, $Body) try { return Invoke-RestMethod -Uri $Uri -Headers $Headers ` -Method $Method -Body $Body } catch { $resp = $_.Exception.Response if ($resp) { $reader = New-Object System.IO.StreamReader($resp.GetResponseStream()) $errBody = $reader.ReadToEnd() Write-Warning "HTTP $([int]$resp.StatusCode): $errBody" } throw } }这样至少能区分 401(API Key 无效或过期)、429(请求频率超限)和 5xx(服务端问题)。面板里遇到 401 时我会直接把状态栏标红,提醒去检查 Key,而不是让程序静默失败。
3. WinForms 面板:从一个空窗口到能用的桌面工具
3.1 在 PowerShell 中初始化 WinForms 环境
PowerShell 里写 WinForms 的第一步是加载两个程序集:System.Windows.Forms和System.Drawing。前者提供窗体、按钮、标签这些控件,后者提供字体、颜色、图标等绘图相关类型。
Add-Type -AssemblyName System.Windows.Forms Add-Type -AssemblyName System.Drawing这里有个细节:如果脚本可能在 PowerShell 7(pwsh)里运行,要注意线程模型。WinForms 要求消息循环跑在 STA 线程上,PowerShell 5.1 默认就是 STA,但 PowerShell 7 默认是 MTA,需要用pwsh -STA启动脚本。稳妥起见,我在脚本入口处显式检测一下[Threading.Thread]::CurrentThread.ApartmentState,如果不是 STA 就直接警告退出。
窗体的创建是纯手写布局,没有设计器。我的做法是先定义好窗体尺寸和标题,再把子控件一个个 Add 进去,最后用$form.ShowDialog()进入消息循环。
3.2 主面板布局与核心控件
面板的布局我设计成上下两段:上段是四个大数字标签,分别显示总余额、赠送余额、累计 token、今日 token;下段是一个 ListView,按时间倒序展示最近 20 次请求的模型、token 明细和时间。顶部再放一个 Key 输入框和“测试连接”按钮,方便首次配置时验证。
控件的核心代码长这样:
$form = New-Object System.Windows.Forms.Form $form.Text = "DeepSeek Token 用量面板" $form.Size = New-Object System.Drawing.Size(640, 460) $form.StartPosition = "CenterScreen" $form.MaximizeBox = $false # 顶部:API Key 输入区 $keyLabel = New-Object System.Windows.Forms.Label $keyLabel.Text = "API Key:" $keyLabel.Location = New-Object System.Drawing.Point(15, 15) $keyLabel.AutoSize = $true $keyBox = New-Object System.Windows.Forms.TextBox $keyBox.Location = New-Object System.Drawing.Point(80, 12) $keyBox.Width = 380 $keyBox.UseSystemPasswordChar = $true $testBtn = New-Object System.Windows.Forms.Button $testBtn.Text = "测试连接" $testBtn.Location = New-Object System.Drawing.Point(470, 10) $testBtn.Add_Click({ ... })布局这块我踩了个小坑:PowerShell 里没有 WinForms 设计器,坐标全靠自己算,如果某个控件宽度没算好,窗口拉伸时就会错位。我的处理方式是先把窗口设成固定尺寸不拉伸,等基础功能稳定后再考虑用 TableLayoutPanel 做自适应,不过对于这种小工具,固定尺寸完全够用。
3.3 刷新策略:先同步后异步
刷新数据有两个思路。第一种是最简单直接的:用System.Windows.Forms.Timer定时触发,在 Tick 事件里直接调用 API 函数,把返回值写到 Label 上。
$timer = New-Object System.Windows.Forms.Timer $timer.Interval = 300000 # 5 分钟 $timer.Add_Tick({ $data = Get-DeepSeekSummary # 封装好的数据获取函数 $lblBalance.Text = $data.Balance $lblTokens.Text = $data.TotalTokens }) $timer.Start()注意System.Windows.Forms.Timer的 Tick 事件是跑在 UI 线程上的,所以直接改控件文本是安全的。坏处是如果网络慢,界面会卡住一两秒。我一开始用这个方案,5 分钟卡一次,实际能接受。
第二种是异步方案,用后台线程跑请求,完成后通过Invoke回到 UI 线程更新控件。这个方案更优雅,但代码复杂度会上升。我给出的折中做法是:默认用同步 Timer,如果某次请求超过 3 秒没返回,就把间隔调大,同时在状态栏显示“同步中…”,避免用户误以为程序死了。
3.4 系统托盘与开机自启
既然定位是“放后台一抬眼就能看到”的工具,那么最小化到托盘和开机自启必须安排上。
托盘用 NotifyIcon 实现:
$notifyIcon = New-Object System.Windows.Forms.NotifyIcon $notifyIcon.Icon = [System.Drawing.SystemIcons]::Information $notifyIcon.Text = "DeepSeek Token 面板" $notifyIcon.Visible = $true $menu = New-Object System.Windows.Forms.ContextMenuStrip $openItem = $menu.Items.Add("打开面板") $exitItem = $menu.Items.Add("退出") $notifyIcon.ContextMenuStrip = $menu $openItem.Add_Click({ $form.Show(); $form.WindowState = "Normal" }) $exitItem.Add_Click({ $form.Close() })窗口最小化时自动隐藏到托盘,需要在窗体的 Resize 事件里判断一下 WindowState:
$form.Add_Resize({ if ($form.WindowState -eq "Minimized") { $form.Hide() } })开机自启我选择写注册表HKCU:\Software\Microsoft\Windows\CurrentVersion\Run,因为只对当前用户生效,不需要管理员权限,卸载也方便,删除对应注册表项就行。
$runKey = "HKCU:\Software\Microsoft\Windows\CurrentVersion\Run" $scriptPath = $MyInvocation.MyCommand.Path $command = "powershell.exe -STA -WindowStyle Hidden -File `"$scriptPath`"" Set-ItemProperty -Path $runKey -Name "DeepSeekTokenPanel" -Value $command这里有个小门道:-WindowStyle Hidden只能隐藏控制台窗口,WinForms 窗体本身不受影响,所以用户开机后依然能看到面板;如果只想要托盘里的小图标,可以后续加一个/min参数来以最小化状态启动。
4. 配置持久化:API Key 与历史数据的安全存储
4.1 配置文件放哪、怎么组织
我把配置和历史数据统一存在$env:APPDATA\DeepSeekTokenPanel\config.json。原因很简单:APPDATA是当前用户的应用程序数据目录,其他用户读不到,也不容易被误删;而脚本目录有可能被移动到别的地方,如果把数据和脚本放一起,一旦移动路径变了还得重新找。
配置文件的 JSON 结构大致如下:
{ "apiKeyEncrypted": "01000000d08c9ddf0115d1118c7a00c04fc297eb...", "refreshIntervalMin": 5, "history": [ { "timestamp": "2025-05-20T14:32:10", "model": "deepseek-chat", "promptTokens": 1200, "completionTokens": 300, "totalTokens": 1500 } ], "summary": { "totalTokens": 1500, "todayTokens": 1500 } }apiKeyEncrypted存的是加密后的密钥串,不是明文。history按时间追加,面板启动时读取summary字段来展示累计值,避免每次都要遍历全部历史记录做加总。
4.2 用 DPAPI 给 API Key 加密
PowerShell 里有一对命令天然支持 DPAPI 加密:ConvertTo-SecureString和ConvertFrom-SecureString。当你调用ConvertFrom-SecureString时,默认使用当前 Windows 用户的凭据加密数据,加密结果只能由同一台机器上的同一个用户解密。这对个人小工具来说足够安全。
保存 Key 的逻辑:
$secureKey = $apiKey | ConvertTo-SecureString -AsPlainText -Force $encryptedKey = $secureKey | ConvertFrom-SecureString $config.apiKeyEncrypted = $encryptedKey $config | ConvertTo-Json -Depth 5 | Set-Content -Path $configPath -Encoding UTF8读取 Key 时反向操作:
$secureKey = ConvertTo-SecureString $config.apiKeyEncrypted $apiKey = [System.Net.NetworkCredential]::new("", $secureKey).Password需要提醒的是,DPAPI 加密的结果和用户账户、机器绑定,不能把加密后的字符串直接拷到另一台电脑上解密。如果你的使用场景是多台设备,那得换方案,比如把 Key 放在环境变量里,或者用 Windows 凭据管理器(Credential Manager)托管。
4.3 历史用量累计与去重
本地累计的核心逻辑是:每当你从业务脚本调完 DeepSeek API 拿到usage后,追加一条记录到history数组里。但要防止重复计数,尤其是面板自身如果也发起测试请求,那部分数据混进去会污染统计。
我的做法是给每条记录加一个source字段,区分“手动测试”“业务调用”“面板轮询”,面板轮询只调余额接口,不调聊天接口,所以在源头就不会产生 usage。同时给每次调用生成一个请求 ID,接口返回的记录如果发现 ID 已经存在就跳过,防止网络重试导致重复计费。
数据量大了之后,纯 JSON 文件读写会变慢。我目前的做法是每次追加后只重写 summary 和最近的 100 条历史,更早的记录按月归档成独立文件。对于个人开发者每天几百条请求的规模,这套方案足够跑很久了。
5. 跑了一周后的实测结果、踩坑清单与下一步计划
5.1 一周实测样本
写这篇文章前,我让面板持续跑了一周,同时接了三个业务脚本:一个批量翻译脚本,一个代码解释器,一个个人知识库的向量化任务。实测下来,每天请求量在 200 到 800 次之间。
| 日期 | 请求数 | Prompt Tokens | Completion Tokens | 总计 |
|---|---|---|---|---|
| 周一 | 312 | 214,800 | 45,300 | 260,100 |
| 周二 | 458 | 320,500 | 62,100 | 382,600 |
| 周三 | 201 | 145,200 | 28,400 | 173,600 |
| 周四 | 487 | 351,200 | 71,800 | 423,000 |
| 周五 | 620 | 445,000 | 98,300 | 543,300 |
| 周六 | 88 | 52,000 | 12,500 | 64,500 |
| 周日 | 66 | 38,400 | 9,800 | 48,200 |
从数据里能明显看出工作日的 token 消耗远高于周末,而且 deepseek-reasoner 的使用量虽然次数少,但单次的 completion_tokens 通常是 deepseek-chat 的 3 到 5 倍。这个信息靠控制台很难一眼看出来,但面板按模型拆分的统计能直接反映出来,对后续预算规划很有帮助。
5.2 踩坑清单与排查链路
有几个坑是实打实花时间踩出来的,按影响程度排个序:
第一个坑是 TLS 1.0 导致的请求失败。表现是首次运行脚本时,余额接口偶尔能通、偶尔报错,而且报错信息不明确。排查链路从“是不是 API Key 错了”开始,排除后抓异常,发现是“未能创建 SSL/TLS 安全通道”,再定位到协议版本。这个问题只在 PowerShell 5.1 里出现,pwsh 7 默认走系统 TLS 配置,所以如果你用新版 PowerShell 反而不会遇到。
第二个坑是 401 和 429 的处理。一开始我直接把异常抛出来,界面瞬间无响应,后来发现是 Key 过期了,但界面只显示“Exception”,没有告诉我是哪种错误。封装了错误响应读取函数之后,界面状态栏才真正有用。这里也顺便处理了“token 失效”这类情况:收到 401 时弹一个提示,并自动停止定时器,避免频繁刷屏。
第三个坑是“线程间操作无效”的偶发问题。虽然同步 Timer 的 Tick 在主线程执行,但我后来优化时用了一次后台任务,直接在其他线程里改 Label 文本,Windows Forms 运行时会随机抛异常。解决方式就是在更新控件前判断InvokeRequired,然后用Control.Invoke切回 UI 线程。这个知识点在 C# 里是老生常谈,但在 PowerShell 里容易忽略。
第四个坑是中文乱码。API 请求体如果直接传字符串给Invoke-RestMethod的-Body参数,PowerShell 5.1 默认可能按 ISO-8859-1 编码发送,导致中文 content 在服务端变成乱码。我改成先转 UTF-8 字节数组再传,问题解决:
$bodyBytes = [System.Text.Encoding]::UTF8.GetBytes($jsonBody) Invoke-DeepSeekRequest -Uri $uri -Headers $headers -Method Post -Body $bodyBytes5.3 下一步想加的功能
目前这个面板满足了我的基本需求,但两周用下来还是有几处想继续优化的地方。第一是多账号支持,现在配置里只有一个 Key,我准备把它改成账号列表,下拉切换时整个面板的数据跟着变。第二是预算告警,当余额低于某个阈值或者今日 token 消耗超过设定值时,通过系统通知弹窗提醒,这个对长期挂机任务很有价值。第三是导出报表,把 history 按月导出成 CSV,方便做更详细的分析。
还有一个我在考虑的设计:把面板改造成本地 HTTP 服务,业务脚本调完 API 后直接 POST 一条记录到http://localhost:8080,面板负责写入和展示。这样脚本不需要依赖配置文件路径,面板和业务逻辑彻底解耦。不过这个改造会引入端口监听等额外复杂度,目前暂时不急。
做这个小工具最大的体会是,用 PowerShell 写桌面程序并没有想象中那么“业余”。WinForms 在 .NET 里非常成熟,PowerShell 能直接调用全部类库,缺的只是可视化设计器而已。对于 API 监控、内部工具、自动化面板这类轻量场景,一个 .ps1 文件加几段代码就能解决,比引入一整个技术栈划算得多。
最后再分享一个使用习惯:我后来给面板加了一个最小化参数/min,配合开机自启,平时它只以托盘图标存在,想看数据时点开,手头有活时完全忽略它。真正好用的工具不是功能越多越好,而是能在你需要它的瞬间出现,然后消失到不打扰你的地方。这个小面板目前算是做到了。