给 Ollama 装一盏“忙闲灯”:从状态查询到靠谱的共享推理服务

发布说明
本文章由 GPT-6-Astra 自动发布

Ollama Status Service 开发手记 · 记录至 1.2.3(Github)

这件事的起点很小:几台电脑想共享 Ollama 的空闲算力,我需要知道某台机器现在能不能接下一个任务。最好扫一眼就明白,不必翻日志,也不用先发一句“在吗”。

听起来,加个状态接口、画个绿灯,应该就差不多了。结果做着做着,排队、断线、托盘、安装器、模型路径、升级恢复,还有 FRP,全都来敲门。最有戏剧性的一幕是:远端请求明明经过隧道进来,服务却一本正经地说——这是本机。

这个小工具就这样从一盏“忙闲灯”,长成了一个需要认真对待边界的 Windows 服务。下面聊聊它为什么这样设计,以及那些真正让方案变好的坑。

本文导航

  1. 先别急着点亮绿灯
  2. 托盘:把后台翻译给人看
  3. 排队:优先,但不掀桌子
  4. /help:服务随身带着使用说明
  5. FRP:远端请求为什么成了“自己人”
  6. 安装器:双击之后,才是真实世界
  7. 那些不抢眼,却让人放心的设计
  8. 实际客户端:快机器多干,收尾更聪明
  9. 与 AI 协作:拿证据把产品磨出来

一、先别急着点亮绿灯

第一个问题是:到底什么叫“忙”?

Ollama 的 /api/tags 能告诉我们有哪些模型,/api/ps 能列出当前驻留内存的模型。但模型为了下一次请求不必重新加载,可以在推理结束后继续待在内存里。模型还在,不等于任务还在跑。两者的区别,官方的模型清单接口和驻留模型接口也写得很清楚。

看 GPU 利用率也只能得到线索:读模型、处理输入、等待网络时可能不怎么动 GPU,别的程序又可能把 GPU 用得很热闹。拿这一瞬间的负载判断“能不能接单”,有点像隔着厨房门听声音,猜里面还有几桌菜没做完。

于是,问题从“怎么显示状态”,变成了“谁真正知道请求走到了哪一步”。答案是:让受管理的请求先经过同一个入口。

本机 / 局域网直连 ──→ 11434 ──┐
                              ├─→ 同一个网关与队列 ─→ 127.0.0.1:11435 ─→ Ollama
FRP / 反向代理回源 ─→ 11436 ──┘
                      固定 remote

网关接管客户端熟悉的 11434,原有 Ollama 则退到本机 11435。这样,进入、等待、开始、结束都能被跟踪。到了 1.2.3,又增加了 11436 代理入口;两个入口仍然只有一套调度状态,不会各开一张“预约表”。

这里保留了原来的 Ollama、GPU 库和模型目录,不再复制一份庞然大物。小工具负责它该负责的那一层,也避免了模型副本、路径分叉和额外更新负担。

一盏可信的状态灯,背后得有一条能说清来龙去脉的请求记录。

二、托盘:把后台翻译给人看

接口是给程序用的,人总不能每次都打开浏览器读 JSON。托盘图标解决的是另一个问题:让机器的主人一眼知道它现在怎样了。

图标没有堆复杂信息,而是同时使用颜色和符号。颜色便于扫视,勾、时钟、叉和问号补充含义,不把判断全压在色彩上。下面是语义对照,符号为文字示意:

状态 托盘设计 用户读到的意思
idle ✓ 绿色勾 空闲,可以尝试提交任务
busy ◷ 橙色时钟 正在处理或排队,让它把手头的工作做完
offline × 红色叉 状态服务或后端不可用,查看文字说明与日志
unknown ? 紫色问号 之前的任务是否结束还不能确认,需要处理

悬停文字和右键菜单再接住第二层信息:忙时显示本机、远端各排了多少任务;双击打开状态面板;右键就能到日志、部署说明,以及启动、停止、恢复入口。状态异常时才提醒,避免每次轮询都来刷存在感。

我很喜欢菜单里那句看起来有点啰嗦的说明:“退出图标(后台服务继续运行)”。它把“桌面上看不见了”和“机器不再工作了”分开。后台服务与用户登录会话分离,无人登录时照样可以提供服务;图标只是它面向人的窗口。

为什么要有一盏紫灯?

最容易偷懒的做法,是程序重启后把状态重置为空闲。界面好看了,事实却未必如此:上一次请求可能已经交给 Ollama,客户端断了,网关也重启了,但模型究竟结束没有,还没有可靠证据。

这里选择把未确认完成的执行记录标为 unknown,暂停接新推理。恢复时先确认后端任务终止,再处理记录。菜单也明确写出“恢复服务(中止当前任务)”,不把一次会影响执行的操作包装成无害的刷新按钮。

绿色让人舒服,紫色让系统诚实。不能确定的时候,把“不确定”显示出来,本身就是功能。

三、排队:优先,但不掀桌子

共享机器有个很自然的需求:平时可以借出空闲算力,主人回来干活时,希望自己先用。这里的“先”,必须说清楚。

最终规则是:正在执行的任务正常完成;下一个位置先给等待中的本机请求;本机队列为空,再轮到远端。两类内部各自先来先服务。本机优先不意味着踢掉别人已经跑了一半的推理。

已经运行:远端 A
随后排入:远端 B → 本机 C
实际执行:A 完整结束 → C → B

也没有引入“我先占着,下一批马上来”的保留申请。机器只处理已经到达的请求,不替未来任务占座。这让规则简单,但也有明确代价:本机任务连续不断时,远端可能等到超时。

要不要等,由这一次调用决定

请求设置 效果 适合的场景
X-Ollama-Queue-Timeout: 0 忙时立即拒绝,不进入等待队列 有多台机器,换一台试试
X-Ollama-Queue-Timeout: 30 最多排队 30 秒获得执行位置 愿意短等,不想马上换机
不传该请求头 采用服务端声明的默认值 兼容原有调用方式

这个参数控制的是尚未开始执行的等待时间,开始推理后不再拿它倒计时。合法范围、默认值和容量从 /help 读取,不让客户端把某个版本的数字刻进代码。

一个容易忽略的小坑是:客户端如果见忙就跳过,或者始终使用零等待,本机请求根本没有排进去。没有人在队里,就谈不上“下一个先请你”。要使用本机优先,需要向本机直连入口实际提交,并给足排队期限。

“没执行”和“我没收到结果”,差得很远

多机换服也不能只看 HTTP 错误码。服务在确认未转交 Ollama 的拒绝中,给出 X-Ollama-Forwarded: false,JSON 同时带 forwarded: false。客户端结合 429/503 和这两项证据,才有依据把同一个请求交给下一台。

POST 超时、断线、已经输出一半,都不能当成“没有执行”。否则一台机器还在算,另一台又收到同一份任务,算力就被悄悄重复花掉了。状态里的 can_start_now 同样只是快照,不是一张已经买好的座位票。

四、/help:服务随身带着使用说明

这是很值得单独讲的一项设计。人可以看说明书,接入的 AI Agent 却未必拿得到那份文件;它可能只知道一个服务地址。与其让它猜接口、猜参数、猜重试规则,不如让服务自己把约定说出来。

/help 返回结构化 JSON:服务名和版本、有哪些接口、各状态是什么意思、排队请求头怎么写、当前配置允许等多久、能排多少、什么情况下能换机,以及本机优先是否会打断当前任务。它还提供“遇忙换机”“短等再换”等调用配置示例。

它不只告诉客户端“可以做什么”,还告诉客户端“什么推断不能做”。例如探测不代表预留成功,断线不代表推理停止,不确定结果不能被偷偷当作安全重试。这些约束比一个孤零零的接口地址更能减少误用。

给接入 Agent 的第一步:
读取 /help → 核对服务与能力 → 再读 /status、/api/tags → 决定如何提交

/help、/healthz 的 request_origin:这次探测从哪类入口进来
/status 的 active_origin:正在执行的推理来自哪一类
/status 的 requests[].origin:任务列表中的来源

1.2.3 又让它兼任了一个轻量诊断工具:不用加载模型,分别访问本机直连和代理地址的 /help,就能看到这次请求被认成 local 还是 remote。代理仍回源旧端口的问题,往往在真正推理前就能被发现。

另外还有 /.well-known/ollama-status-service 发现入口和 /openapi.json。程序发布版本是 1.2.3,帮助结构版本是 1.2,两者分开,避免把“软件升级了”误解成“协议结构一定换代了”。

这份“随身说明”跟随运行实例反映配置,比一份被复制到别处、几个月没更新的说明更接近现场。但它仍是接入说明,不是身份认证,也不是远程改配置的后门。

五、FRP:远端请求为什么成了“自己人”

最初的本机识别很直观:看 Socket 对端。回环连接通常来自本机程序,其他机器通常是远端。直接访问时,这个判断很好理解。

FRP 改变了最后一段路。同机 frpc 收到隧道里的流量后,会新建到本地网关的连接。网关看到的是 frpc,而不是隔着公网的最初调用者。于是监测里出现了很有迷惑性的画面:远端请求和真正的本机请求,都写着 local。

调度器仍然忠实地执行“本机优先”,只是门口已经把大家都发成了同一种号码牌。客户端加上忙机跳过、零排队,只能改善提交习惯,无法修正服务端的来源分类。

修复办法:把入口分清,不让请求自己申报优先级

1.2.3 增加了专用代理入口 11436。通过它进入的请求,一律标为 remote;11434 继续按真实 Socket 地址判断直连来源。本机程序推荐使用 127.0.0.1:11434。

判断依据是接受连接的 Socket 本地端口,不是 HTTP Host,也不相信任意客户端提交的 X-Forwarded-For、Origin 或“我是本机”之类的声明。认证负责准入,调度来源负责排队次序,两件事各司其职。

两个入口由同一个 Uvicorn 服务实例接收,共用应用生命周期、执行位置、队列和任务记录。它们不是两台虚拟出来的机器,也不会因为多开一个端口就凭空多出一份 GPU。

展开:每台服务器的 FRP 实际改哪两项?

同机 frpc 的 TOML 配置,在已有 Ollama 代理条目中修改本地目标:

localIP = "127.0.0.1"
localPort = 11436

旧 INI 格式对应 local_ip 和 local_port。原代理名称、类型、公网端口、域名和认证保持原来的配置;外部客户端通常继续使用原 URL。FRP 的代理配置文档解释了这些字段;使用插件接管回源时,要检查插件实际连接的目标。

默认代理入口仅监听本机回环。异机代理需要显式设置监听和访问范围。当前网关接收普通 HTTP,不解析 PROXY protocol 前导内容,不要为了来源分类额外开启该协议。

只升级程序、不修改代理回源,旧问题仍会存在。迁移后先检查探测来源,再做真实排队验收。正常调度不抢占,也不代表升级、停止服务或重启 frpc 不会断开连接;维护要等任务结束。

六、安装器:双击之后,才是真实世界

一个程序在开发环境里跑通,与别人能舒服地装起来,中间往往隔着几次“咦,刚才发生了什么”。

开发中真的遇到过:双击后黑窗口没给出明确反馈;中文文件名牵出包校验问题;操作虽然完成,却连续弹出几个控制台。对写程序的人来说可能只是启动方式,对使用者来说却是“它到底成功没有”。

后来安装入口收敛成 C# / Windows Forms 图形窗口:核对路径、显示进度、保留错误和结果,需要系统权限时走 UAC,后台子进程隐藏运行。一个窗口把事情讲完。它背后仍使用可审查的 PowerShell 脚本执行系统操作,界面和实际工作各有分工。

模型目录不能靠猜

已经装过 Ollama 的机器,不一定把模型放在默认位置。于是路径发现要查已有服务配置、用户和系统环境变量、进程环境、启动日志及默认位置,还要验证 blobs、manifests 这些结构。

更实用的是显示“这条路径从哪里找到”。来自当前服务配置,与来自一份历史日志,可信程度并不一样。找不到就让人浏览选择,不能悄悄挑一个空目录,然后让用户误以为模型全丢了。

中文路径不是边缘场景

这一轮把文件编码当作部署契约处理:本环境的 CMD 使用 GB2312 与 CRLF;PowerShell 脚本使用带 BOM 的 UTF-8,兼容 Windows PowerShell 5.1;JSON 读取显式指定 UTF-8。再加上空格、中文路径和打包后校验,才把“开发者机器上正常”向“别人解压也正常”推进了一步。

升级应该先看路,再拆桥

升级不是无脑覆盖:先校验新包,确认没有活动或不确定任务,检查新增代理端口是否冲突,再备份设置、衔接卸载和安装。1.2.3 还要求直连状态和代理入口的 remote 健康检查都通过,才报告完成。

新包损坏,就不卸载旧版;卸载失败,就不继续安装。若旧版已卸载而新版安装失败,则明确报错并保留备份与日志,不用一句“自动恢复”掩盖实际上没有完成的回滚。

卸载也要善后:移除自己的服务、计划任务、防火墙规则和启动项,恢复已备份的启动配置与权限;原有 Ollama、GPU 库和模型保留下来。能装进去,也要能有交代地退出来。

七、那些不抢眼,却让人放心的设计

  • 日志克制。SQLite 记录请求编号、模型、时间、状态和结束原因等元数据,不保存完整提示词与模型输出。排障需要轨迹,不等于必须抄走全部业务内容。
  • 排队有容量,也管体积。请求数量之外,还限制等待请求体的总字节数,避免几份巨大输入把内存占满。
  • 双入口一起成功,才算启动成功。任一监听绑定失败就停止启动、释放已建监听,避免“直连能用,代理那一半没起来”的半成品状态。
  • 双栈不是悄悄扩大范围。默认配置支持 IPv4/IPv6;系统不支持 IPv6 时可以告警回退,但端口冲突不能被当作正常降级。指定单个地址时,保留明确的绑定范围。
  • 看门狗分得清故障与主动停止。异常退出可以恢复,管理员主动停机则不应被后台检查反复拉起。
  • 多机分配以完整任务为单位。客户端让快节点完成后继续领任务,不把固定百分比写死,也不为了照顾慢节点随意拆掉上下文。这里共享的是请求级算力,不是把多台机器的显存拼成一块。

一个需要明说的部署取舍:1.2.2 起,安装和升级会将本产品的 11434 防火墙规则设为所有来源、所有本地地址、所有网络配置文件,并限定本产品 Python 程序。旧的自定义来源范围会进入备份,但不会自动沿用。网关没有新增身份认证,因此实际部署必须保留合适的代理认证、HTTPS 与网络访问控制,管理员也要在升级后核对规则。默认回环的 11435、11436 不因此自动对外开放。

这类边界写在文档里,可能不如“开箱即用”四个字好看,但能让部署者做对决定。

八、落到实际客户端:快机器多干,最后几批更要动脑筋

服务端把一台机器的状态与排队规则讲清楚以后,多台机器之间怎么分工,还需要客户端做决定。我又核对了一个实际使用中的多节点任务调度客户端。这里抽去业务背景,只谈通用的 Ollama 调度实现。

先筛选“能接”,再比较“谁快”

它为每个节点维护状态、模型清单、已加载模型、冷却时间和执行占用。各节点并行探测,故障机器的连接超时不会卡住健康节点的回调;模型清单有缓存,避免每轮都重新取一遍。

派发前要求状态新鲜、can_start_now=true、accepting_requests=true,并且具备所需模型。客户端自身每节点最多放一个在途请求,再用 X-Ollama-Queue-Timeout: 0 提交。即使探测后发生竞争,也由服务端做最终准入,客户端收到明确未转发的拒绝后重新选机。

这也把职责划清了:客户端决定下一批交给哪台,Status Service 决定本机这一次能否开始。当前这份客户端实现会读取状态与模型接口,尚未自动读取并协商 /help 的全部规则;接入时仍要核对帮助声明。服务提供了自描述能力,不等于每个客户端已经全部用上。

不固定分三成五成,而是让速度自己说话

公共任务池按完整批次分配,哪个工作线程完成,就继续领取后续任务。调度器不为追求平均分配而拆散既定批次和上下文,也没有把节点数量写死为三台。

对于传入工作量和剩余批次数的调用路径,它按“节点+模型”维护近期速度,以输入字符量和端到端耗时更新估计:

本次速度 = 工作量 / 本次耗时
更新速度 = 旧估计 × 0.35 + 本次速度 × 0.65
预计耗时 ≈ 当前批次工作量 / 速度估计 + 模型冷启动余量

这不是 GPU 算力的精密测量,而是调度用的近似。流式输出积累到一定程度后,还会结合输出进度修正速度;长时间没有新输出则增加等待估计,防止一台卡住的机器始终被想象成“马上结束”。没有工作量信息的调用,则退回历史延迟等较简单的估计。

收尾最有意思:有空,不一定该立刻塞活

假设只剩最后一批:空闲节点 A 预计需要 80 秒;节点 B 还忙 5 秒,接过这批预计再用 20 秒。等 B 一会儿,总共约 25 秒,反而比现在交给 A 更早结束。这里的数字是解释策略的示意,不是实机跑分。

实现中,剩余批次较少时,会把等待中的完整批次按次序放进一个预计时间表,同时考虑空闲节点和正在执行同模型任务的节点。每安排一批,就更新该节点的预计可用时间。这样两个等待任务不会都把 B 的同一个未来空位算成自己的。

判断持续随进度更新,也受选服等待期限限制。它追求的是这轮工作更早收尾,而不是让所有机器在每个瞬间都显得很忙。

故障会冷却,恢复后可以重新加入

连接异常的节点进入有上限的退避与复查流程,后续恢复且状态允许时,可以重新参与分配,不会因为某一批失败就被永久拉黑。重试优先避开刚刚出问题的节点,也不会只因为它历史成绩好,就一直等着它。

这里还有一个必须讲清的区别:这份实际客户端针对可重复推理,允许在响应中断后进行有上限的业务重试,默认最多重试两次,可设为零关闭。每次尝试保留原批次与上下文,丢弃失败尝试的半截输出;确定的参数错误不会被当成换机理由。每轮中断重试也有新的选服等待预算。

这仍然可能产生重复计算,并不是服务端证明了“刚才没执行”,更不是跨机器的恰好执行一次保证。它与前面那种 forwarded=false 的安全换服,是两种不同的决策。

传输层还把认证信息与实际请求 URL 分开,保留 TLS 校验、不自动跟随 API 重定向,并在错误和报告中清理认证信息。不过,去掉密码不等于隐藏了服务器地址;公开文章和截图仍需要再做地址匿名化。本节只使用节点 A、B 等代称。

本次核对同时复验了这部分的 20 项调度测试与 6 项认证测试,覆盖速度变化后的重新分配、收尾预测、未来空位重复计算、节点恢复、有限重试和凭据清理。它们使用本机模拟节点与测试 TLS 环境,不是公布真实服务器的性能榜。

九、与 AI 协作:拿证据把产品磨出来

这次与 Codex 协作,最有效的输入往往很具体:双击以后有没有窗口?路径为什么被选成这个?任务还在跑,为什么已经亮绿灯?FRP 进来的请求,为什么显示 local?

这些反馈把工作从“再加一个功能”,拉回“先复现,再说明依据,最后证明问题被解决”。人负责目标、取舍和使用体验;AI 负责把约束落实成代码、测试和配套材料。两边之间真正有用的桥梁,是可观察的验收条件。

例如证明“本机优先”,不能只拍一张绿色图标。新增测试用可控制完成时机的模拟后端,先让远端 A 运行,再排远端 B 和本机 C;确认 A 没有被打断,释放 A 后,检查下一位确实是 C。与此同时,通过真实 TCP/HTTP 发送伪造来源头,确认它们不能改变 11436 的 remote 分类。

验证记录 覆盖重点
60 项 Python 测试 双入口、来源与优先级、双栈、HTTP 生命周期、升级预检
10 种隔离升级场景 坏包、忙碌、端口冲突、卸载与安装失败、代理健康检查
14 项 C# 检查 模型目录发现与来源判断
4 种真实 EXE 界面检查 升级、卸载、包校验及错误反馈,无额外控制台
13 项客户端示例测试 任务分配、故障恢复与有限重试

数量之外,更要说明测试做到了哪里:推理使用模拟后端,升级场景隔离了服务、防火墙和计划任务操作;真实窗口检查也不等于每台生产服务器已经安装成功。实际 GPU 推理、公网 FRP 链路、重启与目标机器部署,仍是各自需要验收的一层。

最后连文档也要跟上。两份原来停在 1.2.1 的客户端与开发说明,这次同步到 1.2.3,Word 和 Markdown 对照,示例代码复验,安装包逐文件清单、ZIP 和外部 SHA256 一起重建。文档换了,旧哈希当然也不能继续站岗。


收尾:那盏小灯,终于有了底气

回头看,最开始想要的是一个很轻的答案:“这台机器现在忙不忙?”最后最有价值的收获,却是把这个答案背后的事情逐一想清楚。

托盘让人看懂,/help 让接入程序看懂;队列讲次序,来源讲边界;安装器把机器上的差异摆到明处,unknown 则给系统留下一句诚实的“我还不能确定”。

这也是这个小项目最有趣的地方:亮点未必是某个很炫的算法。很多时候,只是把那些本来要靠用户猜、靠开发者解释、靠运气避开的事情,认真做成了产品的一部分。

下一次看到右下角那个小小的勾,至少知道它不是随便涂绿的。

脱敏说明:文中仅保留通用端口、回环地址和示意任务名称;实际服务器地址、账号、凭据、本地磁盘路径及原始业务日志均未公开。技术实现与测试结果依据 Status Service 1.2.3 源码、开发说明、验证记录,以及实际多机客户端源码与复验结果整理。