复盘:ModelHost:一把闸门的取舍记录
复盘:ModelHost:一把闸门的取舍记录
这是《本地优先的隐私翻译服务》的补篇。第一篇讲整体设计时,"单任务"只用了一段带过;这篇把它拆开:试过什么、为什么换、最后长成什么样。
采用:SemaphoreSlim(1,1) + Wait(0)
private readonly SemaphoreSlim _gate = new(1, 1);
public ModelLease? TryAcquire(string? modelReference, string engine, string kvCache, out string? busyReason)
{
ObjectDisposedException.ThrowIf(_disposed, this);
TranslationModelInfo info = _registry.Resolve(modelReference);
if (!_gate.Wait(0))
{
busyReason = DescribeBusy(info, engine, kvCache);
_logger.LogInformation("拒绝翻译请求:{Reason}", busyReason);
return null;
}
busyReason = null;
TouchActivity();
return new ModelLease(this, info, engine, kvCache);
}
选它有两条理由:不绑线程,可以跨 await 持有;Wait(0) 是零等待的原子申请,CurrentCount 直接就是状态,宿主不必再存"是否已加载/是否被占用"的布尔副本。
三个容易忽略的地方:
Resolve在Wait之前。 反过来,解析失败抛异常时还得保证闸门已归还,多一条漏还路径。IsBusy只作诊断。 接口里的busy取_active is not null:对用户来说"忙"=有任务在跑,而闸门在空闲卸载、预加载的瞬间也会被占住,拿计数报给界面,状态灯会莫名闪一下。- 闸门只表示"占用",不表示"已加载"。
否掉的三个
| 方案 | 为什么不用 |
|---|---|
lock |
绑线程,任务第一个 await 之后就可能换线程,finally 里还锁会抛 SynchronizationLockException;全程同步阻塞又等于占死一个 Kestrel 线程几十秒 |
bool _busy + Interlocked |
任何一个没被 finally 覆盖的返回都会让标志永久停在 1,服务从此"一直忙";而且说不出"谁在占" |
队列 + Channel |
排队信息量低;MCP 调用超时后任务还在队里白跑;重启后队列无解 |
加载必须挪出受理路径
CPU 后端下 Q4_K_M 冷读近一分钟,权重进系统缓存后重载约几十秒,推理只占一两秒。把加载塞进 TryAcquire,HTTP 请求线程就阻塞几十秒,前端显示"翻译中"却一动不动,SignalR 连第一条事件都发不出去。
受理因此是原子且极快的:要么拿到闸门建任务,要么立刻返回忙。加载慢落成一个可展示的等待状态;受理后被立刻终止也不必白加载一遍(IsCancellationRequested 检查)。
代价是多出一个中间态:闸门被占但内存里还没有模型。DescribeBusy 的第一个分支、前端和查询接口都得认它。
ModelLease:闸门之正确归还
拿到闸门之后必须保证还回去。做法是包一个 IDisposable 会话对象:
public sealed class ModelLease : IDisposable
{
private ModelHost.LoadedModelState? _state;
private int _disposed;
public HunyuanDenseModel EnsureLoaded(ModelHost.ModelStatusReporter? status = null)
{
ObjectDisposedException.ThrowIf(_disposed != 0, this);
_state ??= _host.LoadIntoMemory(Info, Engine, KvCache, status);
return _state.Model;
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
_state = null; // 模型归常驻槽位管理,不随会话释放
_host.Release();
}
}
| 写法 | 防的是什么 |
|---|---|
Interlocked.Exchange 置位 |
using 和手动调用都释放一次时,第二次 Release() 会抛 SemaphoreFullException,且抛在不该抛的地方 |
_state ??= |
预加载路径和任务路径都会调 EnsureLoaded,重复调用直接复用 |
_state = null 而不是 _state.Model.Dispose() |
顺手释放会把"常驻"变成"每个任务重载一次":能跑,但每次都很慢,不报错 |
归还链路 ReleaseGate() 上的 ObjectDisposedException(退出阶段与 Dispose 抢)和 SemaphoreFullException(有人多还一次)都吞掉,避免让一个计数异常把已经成功的翻译判成失败。
"忙"这个状态分解
最早的报错只有三个字,谁在占、是不是卡死了、要不要重启都没有答案。现在按五种情况给原因,每种指向不同的下一步:
| 情况 | 说明里的关键信息 | 用户该做什么 |
|---|---|---|
| 闸门被占、内存里无模型 | 正在准备模型 | 等加载完,或查当前任务 |
| 模型不同 | 内存中只常驻一个,换模型需先卸载 | 等它结束,它不会自动让位 |
| 模型同、引擎不同 | 切换引擎需要先卸载模型 | 等,或停止后换回同一引擎 |
| 模型同、KV 策略不同 | 当前用的是另一种缓存策略 | 同上 |
| 都相同 | 正被另一个翻译任务使用 | 查询 / 停止 / 重试 |
引擎和 KV 策略也算"不同模型":构造函数接收 KvCacheConfig 和后端实例,换任何一个都得重新构造,而重新构造必须先释放旧实例。常驻判定键因此是「模型 ID + 引擎 + KV 策略」三元组。
MCP 那边给的不是错误码而是动作序列:
模型正忙,本次翻译未开始。
下一步:
1) get_current_translation 查看当前任务的进度与译文,确认是否需要保留已译内容;
2) cancel_current_translation 停止当前任务(已译部分仍可查询);
3) 重新调用本工具开始新的翻译。
实测让 Claude 在任务进行中再调一次 translate_text,它照着这三步查进度、问要不要停、然后停掉重试。错误信息写清楚,等于给 AI 客户端补了一段控制逻辑。
空闲卸载的五个细节
默认 5 分钟空闲卸载,首次检查排在窗口期满,之后每 30 秒复查。实现上有五处细节:
| # | 做法 | 不这么做会怎样 |
|---|---|---|
| 1 | 先算时间差,超窗才抢闸门 | 每 30 秒空抢一次,会在任务刚结束时抢到闸门,把可用窗口打断几十毫秒 |
| 2 | 抢闸门用 Wait(0) 而非 WaitAsync |
定时器回调不是异步方法;"抢不到"完全正常,没有等待的意义 |
| 3 | 抢到后双检时间 | 算完时间到抢到闸门之间可能有新请求把活动时钟拨回去,不双检就会把正在被等待的模型卸掉,下个请求重载几十秒 |
| 4 | 用 Environment.TickCount64,不用 DateTime.Now |
后者受 NTP 与手动改时间影响,负值算出"永不卸载"、巨大值算出"立刻卸载" |
| 5 | 整个回调包 try/catch | Timer 回调跑在线程池,未捕获异常能终止进程;退出阶段还要单独捕 ObjectDisposedException |
一处差点写错的顺序
任务结束时要记入 _lastFinished、把 _active 置空、释放上一个 _lastFinished 的取消源。顺着写会踩两个 bug:释放放在赋值之前,同一任务被重复结算时会把刚完成的取消源释放掉;_active = null 之后、_lastFinished = job 之前有个极短窗口,此时轮询拿到 idle 去 file-result?job= 取到的是上一次的任务,HTTP 200 却数据是错的。
var prev = _lastFinished;
_lastFinished = job; // 先挂新的
_active = null; // 再开闸
prev?.Dispose(); // 最后释放旧的
前端还配了一道防线:补取译文前校验 saved.jobId === state.jobId。两层都做,是因为这类竞态只在高频轮询下偶发,单层防护总会漏。
复盘
站住了的三条:信号量换不得(跨 await 持有);闸门与加载必须解耦(把"加载慢"从 bug 变成可展示状态);拒绝信息必须带下一步(AI 只会照字面执行,比真人更需要它)。
没想清楚的:换引擎等于换模型,界面上是三个独立下拉框、语义却耦合;"正在加载"是加进来才发现的额外状态,省下的复杂度又还回去一部分。还没做的:给 ModelLease 加"等待上限",但这会把队列的复杂度带回来一部分。
604 行里操作信号量的代码不到三十行,剩下全是边界。
本文来自博客园,作者:休休归朴,转载请注明原文链接:https://www.cnblogs.com/am1235/p/23671554

浙公网安备 33010602011771号