本地优先的隐私翻译服务:HyTranslateService|基于混元 Hy-MT2、HyMT2Sharp ,自带 WebUI 与 MCP 接口

这个项目的种子是一个练手的念头:我想从头把一套标准 MCP 服务写通,搞清楚工具注册、参数注入、进度推送、客户端取消每一环到底怎么工作,而不是只会调别人的 SDK。选翻译当载体,是因为它足够具体——输入输出都是文本,效果好坏一眼能看出来,不用编数据集也不用搭前端。

只给 AI 用又太可惜。同一个模型人也要用:粘一段文档、丢一个文件、看着译文一段段出来。所以很早定了一条:MCP 与 HTTP 两个入口平级,共用同一个 TranslationJobManager,谁也不是谁的附属品。

本地优先一半是约束、一半是态度。约束是模型只有 1.8B,跑在本机,扔到云上没有意义;态度是翻译这件事经常不方便外发——内部文档、代码注释、还没发布的接口说明。模型既然能在本机跑,这些文本就一次都不该离开本机。服务因此不依赖任何外部组件:模型下载完即可离线,没有 API Key,没有埋点,译文也不写磁盘。

两个入口,一个内核

graph TB subgraph 入口 A["浏览器 WebUI"] B["HTTP 客户端"] C["AI 客户端 Cursor / Claude / VS Code"] end A -->|"REST"| R["TranslationController"] A -->|"SignalR 事件流"| H["TranslationSignalRHub"] B -->|"REST"| R B -.->|"SignalR(可选)"| H C -->|"MCP Streamable HTTP"| M["MapMcp /mcp"] R --> J["TranslationJobManager"] H --> J M --> J J --> S["TextSegmenter"] J --> E["TranslationEngine"] E --> MH["ModelHost 独占闸门 + 常驻槽位"] MH --> K["HyMT2Sharp 推理内核"] K --> W[("./models/*.gguf")]

两个入口是两种协议族:浏览器与 HTTP 客户端共用 REST(WebUI 另挂 SignalR 收逐段事件,它不是浏览器专属,一次性调用直接用 REST),AI 客户端走 /mcp。两边都只调_jobs.TryStart(request) 拿 JobStartResult,再各自翻成 HTTP 409、SignalR error 事件或 McpException。

MCP 不是 REST 的包装。 任务模型(可取消、可推进度、独占资源)先定义在 TranslationJobManager,MCP 直接用,REST 切一个同步切片,SignalR 切一个事件流。反过来写,等于先在 HTTP 层实现一遍进度与取消、再到 MCP 层拆掉重装。

中间三个组件是全部业务逻辑:TranslationJobManager 管受理与事件,ModelHost 管加载、卸载与独占,ModelRegistry 把 auto / Q4 / 序号 / 文件名解析成具体模型。

五个取舍

1. 单任务,忙则拒绝

同一时刻只跑一个任务,第二个请求立刻被拒,不排队。原因不是"慢"是"错":模型实例内部持有 KV 缓存、前向推进内部序列状态,两个线程同时喂一个实例,输出是乱码或直接崩;多开一个实例等于把权重和缓存再复制一份。

队列推演过没走:排队信息量太低,用户只看到转圈;MCP 的工具调用有超时,任务躺着排队时调用已经失败,轮到它再跑一遍也没人收。所以是一次 Wait(0),零等待,拿不到就带原因返回——HTTP 409 写清"谁在占、下一步做什么",MCP 的 isError 给三步操作(查询 → 取消 → 重试)。AI 客户端不理解 409,但会照着文字执行。

代价:吞吐上限锁死在一个任务。换到:全部并发状态就是一个 _active 字段加一把信号量,没有积压、没有"超时了还在队里"的幽灵。

2. 刷新不丢任务

责任划分上,浏览器只是观察者,不持有任务。客户端断开只停事件推送:TranslationSignalRHub.Translate 捕获 OperationCanceledException 后记一条 debug,不调用 job.Cancel(),服务端跑到终态。

页面加载时问一次 current?brief=1——只回计数、状态和 note。全量快照每次要拼一遍所有片段的译文,几十万段的大文件按 1 秒轮询,时间全耗在拼接上。拿到 kind 后分派,因为"接续"不是一个动作:

kind 接续方式 为什么
file 交给大文件弹窗 resume() 弹窗有自己的进度条和终态下载链路
inline 主窗口每秒轮询全量 current 要顺手恢复原文和已译文本
mcp / mcp-file 只轮询 brief=1,只显示进度 结果由 MCP 交付,界面不该接管编辑区

两处竞态:终态与 _active 置空不同步,轮询可能拿到 idle 而译文还在 _lastFinished 里,补取前必须校验 saved.jobId === state.jobId,否则会把上一个任务的译文当成本次结果返回,HTTP 200 却数据是错的;加载期间没有逐段事件,只有一条 loading 和快照里的 note,轮询端只能靠 note 区分"在加载"和"卡死了"。

前端因此多出一组接续函数和一份 sessionStorage 暂存,换来后端彻底无状态,没有会话与保活。

3. 空白符保真

模型会改动空格、缩进和空行;在提示词里加一句"保持原有格式",对 1.8B 模型作用有限。因此定了一条可验证的硬不变式:

Σ(片段 Prefix + Text + Separator) === 原文,逐字符相等。

格式从语义里剥出去,模型只看要翻译的字:

  1. 逐行拆分,片段绝不跨行——换行是天然边界。
  2. 换行、缩进、Tab、连续空格不进模型,挂到片段两端的 Prefix(行首)和 Separator(行尾)。
  3. 只有"恰好一个空格"随文翻译——英文词间那个空格必须留给模型,否则译文拼不起来。

第三条是唯一的例外,判断落在 TextSegmenter.SplitUnits 的空白串长度上:

int k = j;
while (k < length && char.IsWhiteSpace(block[k])) k++;
bool soft = k - j == 1 && c == ' ';   // 恰好单个空格:随文空白,不切
if (soft) { j = k; continue; }
contentEnd = j;   // 空白串之前是内容
j = k;            // 串本身归 Separator

效果:SRT 时间轴和序号一字不动,Markdown 列表层级、代码围栏、空行全保留,对齐的日志片段列宽不变。

代价是段数变多:每段都要 ResetCache() + 前向一次提示词,整体比整篇扔进去慢。Prefix / Separator 必须一路带到前端,segment_done 事件专门带 separator 字段,漏拼就丢格式。

4. 译文不写磁盘

没有任何把译文写到磁盘的代码路径:没有 translations/ 目录,没有 --output-dir,没有"取输出文件"的接口。文件翻译是上传 → 读进内存 → 翻译 → 结果留在 _lastFinished → 用 file-result?job= 取走。

代价是只保留最近一次:上一个任务的译文被下一个覆盖,没有历史、没有任务列表,重启全消失。但一旦开始写文件,就得回答"临时文件什么时候删""崩溃残留怎么办""别人拿到这台机器能不能翻出我翻过什么";"只保留最近一次"一句话说得清。需要留档时由使用者自行保存。

5. 内核以源码内置,发布不裁剪

HyMT2Sharp/ 是整个 2.0.0 内核源码,ProjectReference 参与编译,不走 NuGet:一是打了本地补丁(Vulkan 后端在开了 ReBAR 的机器上会优先选一块只有 214 MB 的 DEVICE_LOCAL|HOST_VISIBLE|HOST_COHERENT 堆,直接 ErrorOutOfDeviceMemory,补丁要求这类堆 ≥ 1 GiB 才优先),补丁活不过打包;二是调推理问题能单步进内核。

代价是升级要手工做:上游把 TargetFramework、ImplicitUsings 放在它自己仓库根的 Directory.Build.props 里,整目录覆盖时必须把 HyMT2Sharp.csproj 补回来,否则会出几百个 CS0029。

发布形态是框架依赖、不裁剪、不 AOT:反射式 JSON 序列化、SignalR 动态代理、MCP SDK 的反射式工具发现都不适合 AOT,收益远小于"能编译成功、运行就崩"的风险。

跑起来

编译运行,浏览器打开 http://localhost:8086。模型不用提前准备,下拉里选一个,没下载过的会自动下载到 ./models。
服务端不读也不写任何配置文件:

参数 默认 说明
--host / --port 127.0.0.1 / 8086 改 0.0.0.0 前先想清楚没有认证
--engine auto 有可用 GPU 就用,失败回落 CPU;gpu 强制不降级
--kv-cache memory none 则任务结束即释放
--model-idle-unload 5 空闲多少分钟卸载模型,0 禁用
--segment-chars 480 单段最大字符数
--max-text-bytes 131072 内联文本上限;超限报错,需拆分或改用文件上传
--max-file-bytes 33554432 文件上传上限
--no-file-tools 启用 停用 MCP 的 translate_file(工具仍列在 tools/list,调用返回 isError);不影响 WebUI 文件上传

用法

WebUI:粘贴原文、选语言、点翻译,支持文件上传、术语表、风格、引擎与 KV 选择,翻译中逐段出译文。若"忙"是别的客户端造成的(比如 MCP 正在跑),本页收到 409 后会再探一次 current?brief=1,确认有任务就放出「终止翻译」——cancel 停的是当前任务,不要求由本页发起;否则用户点了没反应,模型却一直被占着。

HTTP:POST /api/translation 同步返回,长文本内部拆段;文件走 POST /api/translation/file → GET /api/translation/current?brief=1 轮询 → GET /api/translation/file-result?job= 取回(只在内存)。要实时过程连 SignalR /signalr/translation,调 Translate(request),事件只推调用方连接,序列 preparing → planned → loading → started → segment / delta / segment_done → done / cancelled / error(started 由 TranslationJob.cs 硬编码推送,JobEventTypes 无对应常量)。

MCP:端点 http://localhost:8086/mcp,6 个工具——translate_text、translate_file、list_translation_models、list_target_languages、get_current_translation、cancel_current_translation。参数名是 C# 形参经 SDK 序列化得到的驼峰形式(targetLanguage、segmentChars、kvCache),工具描述按同一份写。

这些取舍有一条共同线:格式还原、并发和接续都交给确定性规则,不交给模型,也不引入会话状态。译文只留在内存里,需要留档时由使用者自己保存。

项目地址:https://github.com/am1235/HyTranslateService

posted @ 2026-11-07 18:17  休休归朴  阅读(6)  评论(0)    收藏  举报