复盘: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 连第一条事件都发不出去。

graph TD A["入口调用 TryStart"] --> B["解析参数 / 分段"] B --> C["ModelHost.TryAcquire"] C --> D{"_gate.Wait 0"} D -->|"拿不到"| E["DescribeBusy → 409 / error / McpException"] D -->|"拿到"| F["ModelLease 只占闸门 → 建 TranslationJob"] F --> G["Task.Run 执行 RunAsync"] G --> H["lease.EnsureLoaded 在任务线程加载"] H --> I["推 loading 事件与快照 note → 逐段推 delta"] I --> J["finally 归还闸门"]

受理因此是原子且极快的:要么拿到闸门建任务,要么立刻返回忙。加载慢落成一个可展示的等待状态;受理后被立刻终止也不必白加载一遍(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 行里操作信号量的代码不到三十行,剩下全是边界。

posted @ 2026-11-15 13:53  休休归朴  阅读(4)  评论(0)    收藏  举报