Metadata-Version: 2.4
Name: qwen-mt-cli
Version: 1.0.0
Summary: Qwen-MT CLI - Command-line translation tool with vector-based term matching, running on Qwen-MT, AnyTrans, or any general-purpose model on Alibaba Model Studio
Project-URL: Homepage, https://github.com/leiyu/qwen-mt-cli
Project-URL: Repository, https://github.com/leiyu/qwen-mt-cli
Project-URL: Issues, https://github.com/leiyu/qwen-mt-cli/issues
Author-email: 镭屿 <jiayu.cly@alibaba-inc.com>
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: cli,dashscope,i18n,localization,qwen,translation
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: Apache Software License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Internationalization
Classifier: Topic :: Text Processing :: Linguistic
Requires-Python: >=3.10
Requires-Dist: alibabacloud-anytrans20250707>=2.0
Requires-Dist: alibabacloud-credentials>=0.3
Requires-Dist: click>=8.1
Requires-Dist: dashscope>=1.0
Requires-Dist: httpx>=0.24
Requires-Dist: openai>=1.27
Requires-Dist: openpyxl>=3.1
Requires-Dist: pandas>=2.0
Requires-Dist: prompt-toolkit>=3.0
Requires-Dist: rich>=13.0
Requires-Dist: zvec>=0.3
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == 'dev'
Requires-Dist: ruff>=0.4; extra == 'dev'
Description-Content-Type: text/markdown

# QMT - Qwen-MT CLI

命令行翻译工具，默认使用百炼上的**通用大模型** `qwen3.8-max`，同时支持**通义多模态翻译 (AnyTrans)** 与 DashScope Qwen-MT 系列。通用大模型没有服务端翻译协议，术语干预、翻译记忆、领域提示改由提示词在客户端重建，详见 [通用大模型后端](#通用大模型后端)；要走 AnyTrans 的服务端术语干预与 BatchTranslate 摊销，显式加 `-m anytrans`。提供单句翻译、交互式 REPL、CSV/Excel 批量翻译、半价批量推理，以及术语表、翻译记忆、领域提示、向量语义匹配等专业翻译辅助功能。

## 安装

需要 Python >= 3.10。

```bash
pip install qwen-mt-cli
```

或从源码安装：

```bash
git clone https://github.com/leoleils/qwen-mt-cli.git
cd qwen-mt-cli
pip install -e .
```

安装后即可使用 `qmt` 命令。

## 配置

### DashScope（默认模型所需 / 向量语义匹配 / qwen-mt-*）

设置 DashScope API Key（[申请地址](https://dashscope.console.aliyun.com/)）：

```bash
export DASHSCOPE_API_KEY="sk-xxxxxxxx"
```

> 默认的 `qwen3.8-max`、其余通用大模型后端（`-m qwen3.8-flash`、`-m llm:<名>`，含 `--batch-api`）、`qwen-mt-*` 模型，以及向量语义匹配，走的都是同一个百炼账号与同一把 Key。**只有 `-m anytrans` 不用它。**

**端点默认打北京百炼。** 要换区域（新加坡 / 美国 / 香港）、换业务空间专属的 MaaS 网关、或换任意 OpenAI 兼容的第三方网关，用[端点档案](#端点档案多区域切换)：它把 base URL 与**该区域的** API Key 绑成一对存起来，一条命令切换。`QMT_BASE_URL` 仍然可用，是 CI 与临时覆盖的逃生口。

### AnyTrans（`-m anytrans` 时需要）

设置阿里云 AccessKey 和业务空间 ID：

```bash
export ALIBABA_CLOUD_ACCESS_KEY_ID="your-ak-id"
export ALIBABA_CLOUD_ACCESS_KEY_SECRET="your-ak-secret"
export ANYTRANS_WORKSPACE_ID="llm-xxxxxxxx"
```

或通过 `qmt config set` 持久化保存：

```bash
qmt config set --access-key-id <AK> --access-key-secret <SK> --workspace-id <WID>
```

### 项目默认值（语向 / 场景 / 术语检索模式）

四个键可持久化到 `.qmt/config`，省去每条命令重复指定：

```bash
qmt config set --source-lang Chinese --target-lang Korean --scene mt-turbo --rag-mode cloud
qmt config show     # 逐项标注每个值的来源 (CLI / env / 项目 / 全局 / 默认)
qmt config clear --rag
```

优先级：命令行 > 环境变量（`QMT_SOURCE_LANG` / `QMT_TARGET_LANG` / `QMT_SCENE` / `QMT_RAG_MODE`）> 项目 `.qmt/config` > 全局 `~/.qmt/config`，逐键覆盖。显式 `-t English` 永远赢过持久化的 `target_lang`。

> **副作用**：这四个键对**所有后端**生效，不只 AnyTrans。持久化 `target_lang = Korean` 之后，`qmt -m qwen-mt-plus "hello"` 也会翻成韩语。

`rag_mode` 的完整说明见 [术语 RAG 模式](#术语-rag-模式)。

### 端点档案（多区域切换）

**百炼的 API Key 是分区域的**：一把 Key 只在签发它的那个区域生效，配上别的区域的 base URL 直接 401（[官方说明](https://help.aliyun.com/en/model-studio/base-url)）。所以端点档案把 `base_url` 与该端点的 `api_key` 绑成**一对**存取，切换时一起换——只换 URL 不换 Key 正是这个 401 的成因，而它的报错完全看不出因果。

```bash
# 建档案：--base-url 收完整 URL，也收下面四个预置区域名
qmt endpoint add sg --base-url singapore --api-key sk-xxxxxxxx
qmt endpoint add gw --base-url https://my-gateway.example.com/v1 --api-key sk-yyyyyyyy

qmt endpoint use sg          # 设为生效（写进 .qmt/config，之后每条命令都用它）
qmt endpoint list            # 档案 / 层级 / 生效项 / 本次运行实际会用的 URL 与 Key 来源
qmt endpoint remove sg
qmt --endpoint sg "你好" -t English   # 只覆盖这一次运行，不改配置
```

`--global` 在 `add` / `use` / `remove` 上都可用，写到 `~/.qmt/config`。档案名限 1-32 个 `A-Za-z0-9_-` 字符（**不能带点**，点是配置分层的分隔符），URL 必须以 `http://` / `https://` 开头。预置区域名只在 `--base-url` 处展开，`--endpoint` 只认已存档案名或完整 URL。

| 预置名 | base URL |
|---|---|
| `beijing`（默认） | `https://dashscope.aliyuncs.com/compatible-mode/v1` |
| `singapore` | `https://dashscope-intl.aliyuncs.com/compatible-mode/v1` |
| `us` | `https://dashscope-us.aliyuncs.com/compatible-mode/v1` |
| `hongkong` | `https://cn-hongkong.dashscope.aliyuncs.com/compatible-mode/v1` |

业务空间专属的 MaaS 端点（形如 `https://ws-xxxx.<region>.maas.aliyuncs.com/compatible-mode/v1`）没有预置——它需要控制台才知道的 WorkspaceId，照上面的 `gw` 例子原样填即可。

**跟随档案的流量**：通用大模型后端（含 `--batch-api` 的文件上传、提交、轮询、下载）、`qwen-mt-*`、以及本地 RAG 的 `qwen3.7-text-embedding-flash`。**`-m anytrans` 不跟随**：它用账号级 AK/SK 签名，endpoint/region 钉在 `cn-beijing`。

优先级：`--endpoint` > `QMT_BASE_URL` > 项目 `.qmt/config` 的生效档案 > 全局 `~/.qmt/config` 的生效档案 > 默认（北京）。

两条与上面[项目默认值](#项目默认值语向--场景--术语检索模式)不同的分层规则，都是为了防止半覆盖拼出一个必然 401 的组合：

- **档案整体覆盖，不逐键合并。** 项目级的同名档案整条遮蔽全局级的。逐键合并会把项目的 URL 拼到全局的 Key 上，而这个错配发生在配置文件内部，没有任何一层能看见它。
- **`QMT_BASE_URL` 整体胜出，连带丢弃档案的 Key。** 它是 CI 与临时覆盖的逃生口，行为与引入档案之前一致；但 shell 里 export 的北京 URL 配上档案里存的新加坡 Key 同样必然 401，所以这里打一条醒目警告并点名被覆盖的档案，Key 回落到 `--api-key` / `DASHSCOPE_API_KEY` / 配置里的 `api_key`。刻意不提供 `QMT_ENDPOINT`（档案*名*）环境变量：它带不了 Key，等于把刚消掉的半覆盖请回来。

档案没存 Key 时走上面那条回落链并提示一次——档案可以只存 URL。

**翻译缓存不按端点隔离。** 同一个模型换个区域还是同一个模型，`cache.db` 共用，跨区域重跑照样命中。

**向量索引会记下建库时的端点。** 换区域后本地检索仍然可用（索引本身不依赖端点），`qmt vectordb status` 会指出不一致并建议 `rebuild`，翻译时只警告一次、不失败。嵌入模型变更则相反，是硬失败。注意 `qwen3.7-text-embedding-flash` 是分区域上架的，新加坡端点没有这个模型——切到那边嵌入调用会直接 404，需要先把模型换回该区域可用的。

401 / InvalidApiKey 的报错会多一行诊断，点名当前端点、档案名与 Key 的来源。`qmt config show` 的 **API Endpoint** 段是同一份信息的常驻版本，换端点后先跑它确认。REPL 里用 `/endpoint`，见[交互式模式](#交互式模式)。

## 快速开始

```bash
# 翻译文本（默认 qwen3.8-max，通用大模型后端）
qmt "你好世界" -t English

# 切换到 AnyTrans（服务端术语干预、专家角色、BatchTranslate 摊销）
qmt "你好世界" -t English -m anytrans

# 指定专家角色（仅 -m anytrans 生效，不带 -m anytrans 会被静默忽略）
qmt "你好世界" -t English -m anytrans --agent game

# 切换到 DashScope 翻译模型
qmt "Hello World" -t Chinese -m qwen-mt-plus

# 换一个通用大模型
qmt "Hello World" -t Chinese -m qwen3.8-max

# 百炼上的任意模型
qmt "Hello World" -t Chinese -m llm:qwen-plus

# 翻译文件
qmt -f article.txt -t English

# 管道输入
echo "你好" | qmt -t English

# 流式输出（默认模型即支持；qwen-mt-flash/lite 与所有通用大模型也支持）
qmt "你好世界" -t English --stream

# 交互式模式
qmt -i -t English

# 列出所有模型及其能力矩阵
qmt models
```

## 可用模型

`qmt models` 打印的就是这张表，且直接读注册表，不会与代码漂移——以它为准。

| 模型 | 后端 | 特点 | 流式 | 批量推理 | 语言数 |
|------|------|------|------|------|--------|
| `anytrans` | AnyTrans | 通义多模态翻译，服务端术语干预/专家角色/BatchTranslate | - | - | 92 |
| `qwen-mt-plus` | DashScope 翻译 | 最高翻译质量，适用于专业领域 | - | - | 92 |
| `qwen-mt-flash` | DashScope 翻译 | 通用首选，效果/速度/成本平衡 | 支持 | - | 92 |
| `qwen-mt-lite` | DashScope 翻译 | 最快响应速度，适用于实时场景 | 支持 | - | 31 |
| `qwen3.8-max` | 通用大模型 | 通义千问 3.8 旗舰 (默认) | 支持 | 支持 | - |
| `qwen3.7-plus` | 通用大模型 | 通义千问 3.7 Plus，质量/成本平衡 | 支持 | 支持 | - |
| `qwen3.8-flash` | 通用大模型 | 通义千问 3.8 Flash，响应最快 | 支持 | - | - |
| `deepseek-v4-pro-0813` | 通用大模型 | DeepSeek V4 Pro (第三方，百炼托管) | 支持 | - | - |
| `kimi-k3` | 通用大模型 | Kimi K3 (第三方，百炼托管) | 支持 | - | - |

通用大模型的语言覆盖由模型本身决定，不是翻译协议规定的清单，所以不列数字。

百炼上的其他模型用 `-m llm:<模型名>`（如 `-m llm:qwen-plus`）。注册表里没有的名字，能力位按最保守方向推断：假定支持流式（判错最坏是端点返一个明确错误），假定支持批量推理（判错最坏是任务在 `validating` 阶段被拒，此时不计费）。

`批量推理` 为 `-` 的模型带 `--batch-api` 时会提示一次并回退到实时并发路径——不是报错，因为百炼本来就会在 `validating` 阶段拒绝它们；但回退后按**实时全价**计费，这一点会明确说出口。

`llm:` 只接受模型名，不接受保留名：`-m llm:anytrans` 与 `-m llm:qwen-mt-plus` 会在发请求前被拒绝，因为这些模型的翻译能力来自服务端私有协议，提示词替代不了。

### 选型实测

2026-09，中→韩，1000 行 MMO 修仙文案，1135 条术语，同一份领域提示词，四个全新无缓存目录。裁判 `qwen3.7-plus`——不是四个选手中的任何一个，否则自我偏好会污染结果；抽 200 行，每行跑两次不同的标签洗牌（四份译文随机分到甲乙丙丁）消除位置偏见。

| 模型 | 术语 | 语义 | 语域 | 格式 | 综合 | 平均名次 |
|------|------|------|------|------|------|----------|
| `qwen3.8-max` | 4.88 | 4.84 | **4.72** | 4.98 | **4.85** | **1.90** |
| `llm:deepseek-v4.1-flash` | 4.76 | 4.72 | 4.61 | 4.89 | 4.75 | 2.22 |
| `qwen3.8-flash` | 4.77 | 4.79 | 4.12 | 4.96 | 4.66 | 2.48 |
| `qwen-mt-plus` | 4.62 | 4.75 | **2.99** | 4.91 | 4.32 | 3.40 |

两两胜出：max 胜 mt 85.9%、胜 flash 66.3%、胜 deepseek 58.0%；deepseek 胜 flash 58.0%。

**语域是唯一拉开差距的维度——但这只是中→韩的结论。** mt 2.99 对 max 4.72，其余三维四个模型全在 0.3 分以内。历次对比（max vs flash、max vs deepseek）都是同一个结论。纯规则指标独立印证了这件事：领域提示词要求 UI 文案简洁无敬语，而 mt 有 62.5% 的行用了 `합니다` 体（max 11.4%、deepseek 14.1%、flash 38.0%），长度比中位数 2.17 对其余的 1.50–1.67——mt 把 UI 短语写成了完整敬语句。

**别把这条搬去别的语向。** 同一个 mt、同一份领域提示词，换到中→日就基本合规了，于是语域不再是区分维度，max 改成四个维度全部第一地赢——见[日语复核](#日语复核)。「选后端先看敬语率」是中→韩这一轮的经验，不是通用启发式：敬语率衡量的是模型对**你这个语向的**语域要求的服从度，换语向必须重测。

**术语的字面命中率不能单独拿来排名。** mt 走服务端受约束解码，字面命中却是四个里最低的：

| 模型 | 字面命中 | 裁判术语分 | 残留中文 |
|------|----------|------------|----------|
| `qwen3.8-max` | 95.3% | 4.88 | 0 |
| `qwen3.8-flash` | 94.4% | 4.77 | 0 |
| `llm:deepseek-v4.1-flash` | 91.5% | 4.76 | 2 行 |
| `qwen-mt-plus` | 91.4% | 4.62 | 3 行 |

字面命中算的是"译词是否作为子串出现"，它把两类完全不同的东西混在一起。mt 漏的 33 个术语里 14 个 max 也漏——那是四个模型共同的坑，`开启→오픈`、`装备→장비`、`高级→고급` 这类词性与词边界误判（`装备` 作动词该译 `장착`，`最高级` 里含 `高级`）。mt 独有的 44 次里，23 次是同一个条目 `已达上限→상한 달성`：mt 译成完整句 `상한에 도달했습니다`，max 译成名词短语 `상한 달성`，两个语义都对，但只有后者字面命中，而且后者才符合领域提示词的"UI 简洁"。真错也有——`该槽位未激活` 被 mt 译成"未被解除的槽位"。mt 那 3 行残留中文是把汉字原样留在了韩文里（`귀蟾왕`、`도화槽`）。

**批量吞吐不是模型速度。** 单条延迟 mt 反而最快：

| 模型 | 单条延迟 | 批量有效 | 倍率 |
|------|----------|----------|------|
| `qwen-mt-plus` | **1.50s** | 2.57s/行 | 0.6× |
| `qwen3.8-max` | 1.88s | **0.26s/行** | 7.2× |
| `qwen3.8-flash` | 2.03s | 0.50s/行 | 4.1× |
| `llm:deepseek-v4.1-flash` | 9.29s | 1.11s/行 | 8.4× |

四个都是并发 5、批 10。其余三个拿到了 4–8 倍并行加速，mt 只有 0.6 倍——比串行还慢。本轮日志里没有可见的限流记录，所以不能坐实原因；机制上的嫌疑是[全局退避](#批量翻译故障排查)：一个车道吃到 429 会暂停所有车道。结论是**别按 `行/分` 给模型排速度**，那个数字混进了退避等待。deepseek 的 9.29s 是模型自身的（思考型，还出现过一次 28.69s），不适合实时场景。

`llm:deepseek-v4.1-flash` 必须配 `--no-thinking`，否则 1.9% 的行会直接失败——见[「思考模式」](#思考模式)。

#### 日语复核

2026-09，中→日，500 行，7265 条术语，同一份领域提示词（多了一节段位名冲突说明），四个全新无缓存目录。裁判、抽样、标签洗牌口径与中→韩那轮一致。

**两轮不能直接比数字**：语料零重合，术语表 7265 条对 1135 条，行数 500 对 1000。能跨轮比的只有结论的结构。

| 模型 | 术语 | 语义 | 语域 | 格式 | 综合 | 平均名次 |
|------|------|------|------|------|------|----------|
| `qwen3.8-max` | **4.89** | **4.82** | **4.81** | **4.89** | **4.85** | **2.19** |
| `llm:deepseek-v4.1-flash` | 4.81 | 4.79 | 4.72 | 4.80 | 4.78 | 2.40 |
| `qwen3.8-flash` | 4.83 | 4.66 | 4.81 | 4.73 | 4.76 | 2.42 |
| `qwen-mt-plus` | 4.64 | 4.64 | 4.29 | 4.74 | 4.58 | 3.00 |

两两胜出：max 胜 mt 69.6%、胜 flash 56.0%、胜 deepseek 55.8%；flash 对 deepseek 50.8%，是平局。耗时 mt 31m22s、flash 6m26s、deepseek 4m07s、max 2m04s——大模型反而最快这个反直觉结果与中→韩一致，[同一个警告](#批量翻译故障排查)适用。

**max 仍然第一，但赢法完全不同。** 中→韩是一维拉爆（语域差 1.73，其余三维 ≤0.3），中→日是四个维度全部第一的广谱领先，语域只差 0.52。

**mt 的敬语率在日语里是假信号。** 它 56 行用了 です・ます体（11.2%），按原文长度拆开后是：411 行短 UI 文案里 2 行（0.5%），89 行长句里 54 行（60.7%）。领域提示词只约束「UI 与系统提示文案」，长句用敬体在日语里是对的。**统计敬语率必须按文案类型分层**，否则会把合规的模型判成最差。同一件事的另一面：flash 在中→韩的短板（语域 4.12，`합니다` 体 38%）在日语里不存在（UI 敬语 0/411），于是它和 deepseek 打平了。

**mt 在日语输在结构性的地方，改提示词治不了。** 段位名 `秩序白银V` 该译 `秩序シルバーV`（前缀条目 + 金属条目 + 罗马数字），max/flash/deepseek 15/15 全对，mt 只有 7/15。不是没收到提示词——`domains` 字段确实带上了整份领域提示词（840 字符，没触 3000 上限）——而是结构化 `terminologies` 走服务端受限解码，硬约束压过自由文本指令。另有 15 行把 ASCII `V` 改写成罗马数字码位 `Ⅴ`，21 行把中文人名转写成片假名（`浦立群` → `プーリー群`），而仙侠题材的日文惯例是直接用汉字。

**日语特有的保真缺陷是简体字形漏转，不是残留中文。** 日语目标语本身就写汉字，中→韩那轮的「残留中文」指标在这里失效（`carries_source_script` 刻意排除日语是同一个理由）；「译文与原文逐字相同」也不能当未翻译——500 行里有 37 行四个模型全部原样输出，全是 `夏侯惇`、`九天玄女`、`敖丙` 这类专名，原样是正确行为。真正能规则化检查的是字形：`乐山白` 该写 `楽山白`、`洪泰华` 该写 `洪泰華`。flash 一家 10 行，其余三家各 1 行；裁判给 flash 的语义分 4.66 是三个通用模型里最低的，与此吻合。

**字面命中率这轮会惩罚遵守提示词的行为。** 术语表里 `黄金→金` 与 `荣耀黄金→ゴールド` 并存，max 正确遵守「长条目优先」写出 `栄光ゴールドV`，反被记一次未命中。

### AnyTrans 专家角色

使用 `--agent` 指定翻译专家（仅 `-m anytrans` 生效）：

`game` `medical` `legal` `tech` `finance` `news` `academy` `novel` `ebook` `music` `design` `web3` `github` `twitter` `reddit` `ao3` `chess` `ecom` `vocab` `wordalign` `summarize` `simplify` `mixlang` `dongbei` `bai2wen` `wen2bai` `sublearn`

### AnyTrans 模型场景

使用 `--scene` 选择模型档位（默认 `mt-turbo`）：

- `mt-plus` — 专业版，更高质量
- `mt-turbo` — 轻量版，更快响应

## 通用大模型后端

AnyTrans 与 Qwen-MT 的翻译能力来自**服务端私有协议**：Qwen-MT 读 `translation_options`，AnyTrans 读 `ext.terminologies` / `ext.examples` / `ext.domain_hint`。百炼上的通用大模型不认识这些字段，传了会被忽略。

这个后端把同样的三种干预在客户端重建为提示词，其中术语一分为二——一个源词有多个已批准译词时，它需要的是与术语表其余部分**相反**的指令：

| 干预 | 翻译特化模型 | 通用大模型 |
|---|---|---|
| 术语 | `terms` / `ext.terminologies`，服务端**受约束解码**级硬干预 | system prompt 的 `## Glossary` 段，要求逐字采用 |
| 一词多译的术语 | 从硬约束通道整条撤出，折进 `domains` / `ext.domain_hint` 的自由文本 | `## Terms with context-dependent translations` 段，让模型按上下文从候选里选一个 |
| 翻译记忆 | `tm_list` / `ext.examples`，few-shot 参考 | system prompt 的 `## Approved reference translations` 段，并明确"这是风格示例、不是待译文本" |
| 领域提示 | `domains` / `ext.domain_hint` | system prompt 一行，用户文本**逐字**插入（中文写的领域提示不会被再翻译一道） |
| 专家角色 | `agent` / `ext.agent` | 映射成角色短语替换首句的领域定语 |

上层完全不变：单句、REPL、CSV/Excel 批量、向量语义匹配、`--learn` 回写、缓存、断点恢复都照常工作。

**骨架英文、用户内容原样。** 角色定义与输出纪律用英文写（指令遵从更稳），`domain` / 术语 / 记忆里你的文本一律逐字插入。

**上下文裁剪。** 先按 4000 字符预算裁（术语优先吃预算、记忆填剩余，与其他后端同一策略），再叠一道条数闸门：术语 ≤30 条、一词多译的冲突术语 ≤8 条、记忆 ≤6 条。40 条示例在 AnyTrans 那边是按请求发一次被整批摊薄，在这里是**每行重发**——既烧 token，也让模型更可能去续写示例而不是守规则，所以闸门比字符预算收得更紧。冲突术语的 8 条是**独立**闸门，不继承术语的 30：一行冲突最多渲染 k 个候选 × 120 字符，30 行就是一堵**歧义**墙，而这一节最不能成为的恰恰是歧义墙。

**输出清洗只剥不可能是合法译文的东西。** 外层代码围栏、包裹整句的引号、开头的 `Translation:` 一类前缀标签会被剥掉；但源文自己以引号开头时引号保留，正文中间的围栏和 `Translation:` 不动，尾部的 `\n\n注：` 一律不动——游戏文本里这些都可能是真实台词，剥掉就是数据损坏，还会被 `--learn` 写进 `memory.csv` 变成永久污染。流式输出走同样的清洗。可疑但没被修改的内容用 `-v` 提示，交给你判断。

### 思考模式

qwen3.8 / 3.7 系列**默认开启思考且思考 token 计费**。翻译用不上思维链，所以注册表标记为"默认开"的模型会被主动关掉（发 `enable_thinking=false`），不关就是白烧钱 + 变慢。

实测代价（真实调用，`total_tokens`）。同一行 CSV、12 条术语 + domain 注入：

| 模型 | 不发 flag | 关掉后 | 其中思考 token |
| --- | --- | --- | --- |
| `qwen3.8-flash` | 1019 | 531 | 443 |
| `deepseek-v4-pro-0813` | 1024 | 526 | 418 |
| `kimi-k3` | 1255 | 558 | 623 |

约 2 倍。文本越短比例越夸张——裸发一句 10 字中文时是 115→40、166→21、350→41，思考占比随源文长度摊薄。第三方模型（DeepSeek / Kimi）也认这个参数，说明它是百炼网关层的开关而非模型自带。

```bash
qmt "你好" -t English -m qwen3.8-max                    # 自动关闭思考
qmt "你好" -t English -m qwen3.8-max --thinking         # 强制开启
qmt "你好" -t English -m qwen3.8-max --thinking --thinking-budget 1024
```

注册表里没有的模型（含 `-m llm:<自由名>`）**不发这个参数**：给不认识它的模型发 `enable_thinking` 会直接 400，而判错的代价（多花思考 token）远小于命令失败。要强制就用 `--thinking` / `--no-thinking`，报 400 就说明该模型不认。

判错的代价不止是多花钱。思考预算和答案**共用** `max_tokens`，所以一个没被注册表标出来的思考模型可能把额度全花在思考上、答案被截断，qmt 直接报"输出被 max_tokens 截断"而不是交回半句话。实测 `-m llm:deepseek-v4.1-flash` 跑 1000 行有 19 行（1.9%）这样失败，加 `--no-thinking` 后 19 行全部正常、零吐回。**用 `llm:` 接第三方思考模型时先拿几十行试跑，见到这个报错就加 `--no-thinking`。**

思考内容永远不进输出——只读 `content`，不读 `reasoning_content`。批量推理的结果里根本没有 `reasoning_content`，所以 `--batch-api` 配 `--thinking` 只会烧钱、看不到思考过程。

### 已知限制

**1. 术语干预靠提示词，不靠受约束解码。** `translation_options.terms`（qwen-mt）与 `ext.terminologies`（AnyTrans）是受约束解码级别的硬干预，服务端保证逐字采用；通用大模型后端只是把术语表渲染进 system prompt，那是"请求"而不是约束。glossary 非空且选了通用大模型时会向 stderr 提示一次（管道输出保持干净）。

机制上的差别是真的，但**它在 1000 行 / 1135 条术语的量级上没有转化成可测的遵从度优势**。四方实测里走硬约束的 `qwen-mt-plus` 字面命中 91.4%，是四个里最低的；走提示词的 `qwen3.8-max` 是 95.3%，裁判的术语分两者也只差 0.26（4.62 对 4.88）。详见[「选型实测」](#选型实测)，包括为什么字面命中率本身不能单独当结论——它把词性误判、词边界误判和语域差异混在一个数字里。

早期只测过 12 条武侠术语表（五个预设全部 11/11 命中），那个量级看不出衰减，别拿它外推。

想量化自己的术语表上的差距，跑这两条对比：

```bash
qmt "而这套生物传感器运用了石墨烯" -t English -m qwen-mt-plus
qmt "而这套生物传感器运用了石墨烯" -t English -m qwen3.8-max
```

**2. 占位符可能损坏。** 通用模型会把 `{0}` 规整成 `{ 0 }`、把 `%s` 前后的词一起译掉。system prompt 有保护规则，但只能缓解不能保证。游戏 CSV 里这是致命的，跑完请抽查带占位符的行。

同一类形态也出现在分隔符上。实测 21 行带 `|` 的文案，**`|` 的个数一次都没丢**，但其中 2 行被加了空格：`格挡|15%` → `Block | 15%`、`神兵暴击|983.46万` → `Divine Weapon Crit | 9.8346M`（同批韩文 10 行全干净）。客户端若按 `|` 切分又不 trim，字段就会带前后空格。输出清洗刻意**不**自动删这些空格：分隔符两侧的空格在某些文案里是合法的，按猜测改译文比留着它更危险。代价是**没有任何自动检测**——`-v` 的可疑提示只认尾部的解释块，认不出分隔符空格，所以这一类只能靠人工抽查。

**3. token 成本与 AnyTrans 是两套算法。**

- **实时逐行**：AnyTrans 的 BatchTranslate 把 ext 按请求发一次、被整批 N 行摊薄（约 8 倍降幅，见 [批量为什么省钱](#批量为什么省钱token-摊销)）；通用大模型后端**每行重发完整 system prompt**。10 条术语 + 6 条记忆 + 规则骨架 ≈ 800–1500 input token/行，1 万行 CSV 就是额外 800 万–1500 万 input token。建议从 `-C 3` 起步。
- **`--batch-api`**：单价是实时的 50%，但每行仍带完整 system prompt，且批量推理**不支持上下文缓存**。所以"1 万行 × 1200 token × 半价"仍可能贵于 AnyTrans BatchTranslate 摊薄后的全价。

粗略盈亏平衡（用下面那张表的数字）：设通用大模型单行 system prompt ≈ 1200 token、源文本 ≈ 30 token，则 `--batch-api` 是 1230 token/行 × 半价，**折合 615 token/行的全价开销**；AnyTrans `--batch-size 10` 摊薄后是 **130 token/行全价**。差约 4.7 倍。

**在术语/记忆较重的场景下，AnyTrans 批量仍然明显更便宜**；通用大模型后端的价值在于模型选择面，不在于成本。要压这 615，只能减少注入的术语与记忆条数。

**4. 改提示词骨架后请 `qmt cache clear`。** 缓存键不含提示词版本号，旧译文会继续命中。

**5. 关掉思考可能让模型把原文原样吐回。** 实测 `qwen3.8-flash` 在收到 `enable_thinking=false` 时会把短中文串一字不改地交回来（20/20 确定性复现，不关思考时 0/20）——它把"不要思考"理解成了"不要处理"。两道防线：

- 译文仍带源文字、且本次确实是 qmt 关掉了思考时，自动开着思考重试一次，第二次还不行就交回第一次的结果（不报错，因为那可能只是这一行的问题）。
- 缓存**拒绝落盘**空译文和与原文相同的译文。少了这一道，一次模型抽风会被冻结成永久命中：之后每次都报缓存命中并交回没翻译的原文。

已知缺口：重试的触发条件是"qmt 自己关过思考"。用 `-m llm:<自由名>` 接的未注册模型什么都不发（见[「思考模式」](#思考模式)），它要是自己吐回原文，重试不会触发，那一行会进输出文件——缓存仍然拒绝落盘，所以不会污染后续运行。四方实测里 deepseek 有 1 行是这样。

目标语本身就写汉字时（中→日、`auto`）不做这个检测，否则满是汉字的正确日文译文会被判成未翻译。误报的代价是多调一次，漏报的代价是一行没翻译还被缓存冻住，所以这个检测刻意只覆盖"CJK 源 → 非 CJK 目标"。

## 批量翻译

支持 CSV (.csv/.tsv) 和 Excel (.xlsx/.xls) 文件的批量翻译。翻译文件第一列内容，结果追加为新列。

```bash
# CSV 批量翻译（默认 5 路并发）
qmt -B input.csv -t English

# Excel 批量翻译（所有 sheet）
qmt -B input.xlsx -t Korean

# 指定并发数（1-20）
qmt -B input.csv -t English -C 10

# 指定输出文件
qmt -B input.csv -O output.csv -t English

# 首行也翻译（无表头模式）
qmt -B input.csv -t English --no-header

# 断点恢复（中断后从上次位置继续）
qmt -B input.csv -t English --resume

# 调整每批携带条数（默认 10，范围 1-50，仅 anytrans）
qmt -B input.csv -t English --batch-size 20

# 退化回逐条翻译（逃生口）
qmt -B input.csv -t English --batch-size 1
```

默认行为：
- **批量请求**：仅 `-m anytrans` 走 BatchTranslate，相邻行合并成一个请求（`--batch-size` 控制条数）。默认的 `qwen3.8-max` 与其他后端逐行请求，`--batch-size` 对它们无效，显式传了会 warn 一次
- **并发翻译**：默认 5 路并发调用翻译 API，可通过 `-C` 调整（1-20）。`-C` 始终是**并发请求数**：走 BatchTranslate 时实际在飞行文本数 ≈ `C × batch-size`，逐行后端就等于 `C`
- **自适应限流**：遇到 API 429 自动降低并发度，恢复后自动回升
- **翻译缓存**：相同文本+参数命中缓存时直接返回，不重复调用 API（缓存存储在 `.qmt/cache.db`）
- **分批写入**：翻译完一批立即落盘（默认约 100 行一次），崩溃时未落盘的行由 `--resume` 重译
- 首行视为表头，不翻译（`--no-header` 可改变此行为）
- 输出文件自动命名为 `原文件名_translated.ext`
- Excel 文件翻译所有工作表
- 触发 API 限流时自动指数退避重试（2s -> 4s -> 8s -> 16s -> 32s，最多 5 次）
- 支持 `--resume` 断点恢复，中断后可继续翻译

### 批量为什么省钱（token 摊销）

AnyTrans 按 token 计费，而术语与翻译记忆（ext）是**按请求**发送的。逐条翻译时每行都重发一遍同样的上下文；批量后这份上下文被整批 N 行摊薄。

以典型游戏文本为例（ext ≈ 2000 字符 ≈ 1000 token，单行 ≈ 30 token）：

| 模式 | 输入 token / 行 |
|---|---|
| 逐条 `--batch-size 1` | 1000 + 30 = **1030** |
| 批量 `--batch-size 10` | 1000/10 + 30 = **130** |

约 **8 倍**降幅。摊销随条数单调增强，但官方未公布单请求条数上限，且一条坏数据的爆炸半径随批增大，所以默认保守取 10。跑通后可用 `--batch-size 20~30` 继续压低——单批源文本总字符另有 20000 的自限保护。实际开销在翻译摘要的 `Token 消耗` 行输出。

### 批内上下文共享

同一批所有行**共用**一份术语与翻译记忆：按批做一次块级语义匹配（批内各行向量取最大相关分聚合、取 Top-N、按库顺序输出），而不是逐行各配一份。

- **收益**：索引同步从每行一次降到每批一次（25K 术语 × 1000 行的量级下差异显著）；块内重复短串（"确定"/"取消"/"返回"）从各自 miss 变成同一缓存 key、第二次起直接命中
- **代价**：单行术语召回精度下降。这是刻意取舍——相邻行通常来自同一屏、同一语境，用词高度重叠
- 需要逐行精度时用 `--batch-size 1`

滚动翻译记忆仍跨批、跨并发 lane 共享（一批完成后整批写入，下一批取最近 5 条），但**不参与缓存 key**，否则命中率会随运行顺序漂移。

云端模式下「付一次、处处用」的性质保持不变：整个文件的行在 warm 阶段一次性打包成 TermQuery 单元（每单元 ≤800 字符且 ≤10 行，总数超过 200 次调用则直接降级），之后每个批次的召回都是**纯字典查表、零网络 I/O**。所以批循环里不可能因为检索而触发限流。

### --agent 与批量翻译

`--agent`（27 个专家角色）只有 TextTranslate 支持，BatchTranslate 的 ext 没有该字段。两者共用时批量被**整轮禁用**，降级为逐条 TextTranslate 并提示一次——宁可慢，也不静默丢弃你显式指定的专家角色。

要批量加速就去掉 `--agent`；要专家角色就接受逐条速度。

### 批量推理（`--batch-api`，半价）

**这与上面的 BatchTranslate 是两个东西，只是中文都叫"批量"。** BatchTranslate 是一次请求带 N 行、秒级返回、只有 AnyTrans 有；批量推理是把整个文件拼成 JSONL 上传，任务在服务端排队，**等待以小时计**，单价是实时的 50%。仅通用大模型后端可用。

```bash
qmt -B input.csv -t English -m qwen3.8-max --batch-api -v
```

约束（都是平台限制，不是可调项）：

- **仅 CSV/TSV。** Excel 会**直接报错而不是静默回退**——你点名要半价，悄悄按全价跑实时路径就是撒谎。请先导出为 CSV。
- **仅通用大模型后端。** `-m anytrans` / `-m qwen-mt-*` 带 `--batch-api` 会报错：这些模型的批量能力来自服务端私有协议，批量推理无法表达。
- 注册表标 `批量推理 = ✗` 的模型会提示一次并回退到实时并发路径，**按全价计费**。
- **支持面按端点变，注册表预测不了。** 实测某个国际业务空间端点上，五个预设全部被拒（`not supported by the Batch API`），连百炼文档明确列过的 `qwen3.5-flash` 也拒，而 `qwen-plus` / `qwen-max` 正常完成。注册表那一位是照文档查证的白名单，所以**刻意不据此改成 ✗**——判错的代价不对称：多声明一次只换来一个几十秒内 `failed`、不计费、且报错里写明了出路的任务；少声明一次则是在最多 5 万行上静默付全价。遇到这个报错就照它说的做：去掉 `--batch-api` 走实时全价，或换成该端点接受批量的模型。
- 单个输入文件 ≤ 50000 条请求且 ≤ 500MB；超出自动拆成多个任务。
- 一个文件只能是同一个模型 + 同一种思考模式。
- `completion_window` 固定 24h，但实际等待可能更久；超时就变 `expired`。
- **结果文件 30 天后删除。**
- 批量推理**不支持上下文缓存**，所以每行仍带完整 system prompt。成本对比见 [已知限制](#已知限制) 第 3 条。

命中缓存的行不会进 JSONL——半价的请求也是请求。

**中断与重连。** 提交成功后 batch_id 立刻写入 `.qmt/batch_jobs.json`，然后才开始轮询。所以 Ctrl-C 之后重跑同一条命令会**继续等待已提交的任务，不会重新提交**：

```
检测到未完成的批量任务 batch_xxx，继续等待结果 (未重新提交)
```

匹配条件是 `(输入文件, 输出文件, 模型, 思考模式, 待译行号, 端点)` 全部一致；改了输入或切了[端点档案](#端点档案多区域切换)就不重连、提交新任务。端点必须参与匹配：batch_id 是**该账号该区域**的资源，北京提交的任务在新加坡端点上重连，换来的是数小时等待之后一个莫名的 404。想**放弃**一个在跑的任务：删掉 `.qmt/batch_jobs.json`（任务本身仍会在服务端跑完并计费，只是本地不再等它）。

**部分失败是一等输出。** `completed` 的任务仍可能带一个 error 文件——那些行写 `[ERROR: ...]`，其余行照常，退出码非零。`failed` / `expired` / `cancelled` 的任务若已有输出文件，**已完成的行会先写盘再报错**，因为那些行你已经付过钱了。

### 批量翻译故障排查

| 现象 | 原因与处理 |
|---|---|
| 某行输出 `[ERROR: ...]` | 批量失败后已自动回退逐条 TextTranslate 再试一次，仍失败才标记。`--resume` 只重译这些行 |
| 速度没变快、仍逐条 | `-m` 不是 anytrans；或带了 `--agent`；或 `--batch-size 1` |
| 首批之后全部降级逐条 | 业务空间未开通 BatchTranslate（403/404）。运行级降级已避免每批白付一次失败请求，需去控制台确认权限 |
| 频繁 429 | `-C` 是并发请求数，批量不改变它，但单请求更重。先降 `--batch-size`，再降 `-C` |
| 在飞行行数被自动压低 | `C × batch-size` 超过 100 行软上限时会收窄 lane 数并提示一次 |
| 想量化摊销收益 | 同一份文件分别用 `--batch-size 1` 与默认值跑，对比 `Token 消耗` |
| `--batch-api` 报 Excel 不支持 | 刻意不静默回退。导出为 CSV/TSV 再跑 |
| `--batch-api` 报模型不支持 | `-m` 是 anytrans / qwen-mt-*。批量推理只服务通用大模型后端。这是**提交前**的本地拦截 |
| `--batch-api` 任务提交成功却 `failed`，报 `not supported by the Batch API` | 端点不收这个模型，是**服务端**拒绝，本地白名单预测不了。报错里已写明出路：去掉 `--batch-api` 走实时全价，或换 `-m llm:<该端点支持批量的模型>`。不计费。任务文件保留并标记 `terminal`，重跑会提交新任务而不是重新挂上去 |
| `--batch-api` 提示"已回退到实时并发翻译" | 该模型不在百炼批量支持列表（`qmt models` 的`批量推理`列为 ✗）。回退后是**全价**，不是半价 |
| `--batch-api` 一直显示"排队中" | 正常。轮询退避 10s→60s，进度条读服务端的 `request_counts`，提交后长时间 0% 是预期行为 |
| `--batch-api` 重跑却又提交了新任务 | `pending_row_indices` 变了（输入文件被改过）、端点变了（切了档案），或 `.qmt/batch_jobs.json` 被删了 |
| 任务终止于 `expired` | 超过 `completion_window`。已完成部分若已产出会写盘，job 条目保留在 `.qmt/batch_jobs.json` 供排查 |

## 进度观察与 Hook 唤醒

批量翻译跑起来之后，终端里那条进度条只有盯着它的人能看见。如果命令是后台跑的、或由外部 Agent 调起的，进度就是个黑盒。两件事解决它：运行状态落盘成 JSON 供随时查询，以及终态时敲一下你指定的命令把 Agent 唤醒。

### 查进度：qmt status

```bash
qmt status                  # 人类可读面板
qmt status --json           # 机器可读快照（走 stdout，可直接管道进 jq）
qmt status --all            # 列出近期运行
qmt status --run <run_id>   # 查指定那次运行
qmt status --wait           # 阻塞到最近一次运行结束再输出
qmt status --wait --timeout 600   # 最多等 600s
```

`--wait` 是给收不到 Hook 的 Agent 的兜底：后台跑批之后直接 `qmt status --wait --json`，一次调用拿到结果，不用自己写轮询循环。

状态文件落在 `.qmt/runs/<run_id>.json`，同时维护一份 `.qmt/runs/latest.json` 指向最近一次运行。写入节流到约每 0.5s 一次，阶段切换与终态立即落盘。

快照字段：

| 字段 | 含义 |
|---|---|
| `status` | `running` / `completed` / `failed` / `interrupted` |
| `phase` | `reading` / `embedding` / `translating` / `writing` / `collecting` / `queued` |
| `mode` | `realtime` / `batch_api` |
| `total` / `completed` | 可翻译行数 / 已推进行数 |
| `succeeded` / `failed` / `skipped` | 成功 / 失败 / resume 已跳过 |
| `rows_per_minute` / `eta_seconds` | 速率与预计剩余；运行头 2s 内为 `null`（样本太短，算了会误导） |
| `sheet` | Excel 当前在翻的工作表 |
| `error` | 失败原因 |
| `run_id` / `pid` / `updated_at_epoch` | 运行标识与存活信号 |

`phase` 不是装饰：25k 行文件的向量预热能静默几十秒，没有它你只会看到进度条卡在 0% 而无从判断是在预热还是挂了。`--batch-api` 排队同理，会显示 `queued`。

两个容易踩的坑：

- **`completed` 可能领先 `succeeded + failed`**。`completed` 跟的是进度条（缓存命中、resume 跳过、空行、失败行都算推进），而 `succeeded/failed` 只在每个 chunk 边界对账。判断"跑完了没"要看 `status`，不要看这两个数相加。
- **`stale`**。进程被 SIGKILL 时来不及写终态，`qmt status` 会把它标成 `stale`（`updated_at` 超过 30s 没动）。`--wait` 遇到这种状态以退出码 3 结束，不会无限挂住。无运行记录时退出码是 1。

### Hook：终态唤醒

批量翻译进入终态时执行一条你指定的 shell 命令。Agent 因此不必轮询守候——配一次，跑完自动被叫醒。

三层配置，优先级依次升高：

```bash
# 1. 配置文件（持久，项目级覆盖全局）
qmt config set --hook 'echo "$QMT_EVENT" >> /tmp/qmt-done.log'
qmt config show        # 看当前生效值与来源层
qmt config clear --hook

# 2. 环境变量（临时）
export QMT_HOOK='curl -X POST https://example.com/wake'

# 3. CLI 参数（单次运行覆盖）
qmt -B input.csv -t Korean --hook './notify.sh'
```

三个终态事件共用同一条命令，靠 `$QMT_EVENT` 区分：`batch_complete` / `batch_fail` / `batch_interrupted`。要按事件分流就在命令里判：

```bash
qmt config set --hook '[ "$QMT_EVENT" = batch_complete ] || exit 0; ./review.sh'
```

载荷有两份，命令按需取用：

- **stdin**：完整 JSON，`{"event": "...", "state": {...全部快照字段...}}`
- **环境变量**：关键值摊平成 `QMT_*`，让一行 shell 就能用、不必依赖 jq

| 环境变量 | 内容 |
|---|---|
| `QMT_EVENT` | 事件名 |
| `QMT_STATUS` | `completed` / `failed` / `interrupted` |
| `QMT_TOTAL` / `QMT_COMPLETED` / `QMT_SUCCEEDED` / `QMT_FAILED` / `QMT_SKIPPED` | 计数 |
| `QMT_INPUT` / `QMT_OUTPUT` | 输入与输出文件绝对路径 |
| `QMT_STATE_FILE` | 状态文件路径（想读全量细节就 `cat` 它） |
| `QMT_ELAPSED_SECONDS` / `QMT_RUN_ID` / `QMT_MODEL` / `QMT_TARGET_LANG` | 耗时与标识 |
| `QMT_ERROR` | 失败原因；成功时为空串（不是字面量 `None`） |

唤醒配方，按你的 Agent 运行时挑一条：

```bash
# 写哨兵文件，Agent 侧轮询这个文件即可
qmt config set --hook 'echo "$QMT_STATUS $QMT_OUTPUT" > /tmp/qmt-wake'

# 打 webhook
qmt config set --hook 'curl -sf -X POST -H "Content-Type: application/json" -d @- https://example.com/hook'

# 桌面通知（macOS）
qmt config set --hook 'osascript -e "display notification \"$QMT_SUCCEEDED/$QMT_TOTAL 完成\" with title \"qmt 翻译\""'
```

几条约定：

- **顺序是先落终态状态、再触发 Hook**。Hook 的第一个动作通常就是读状态文件，那时它已经是终态了，不会读到 `running`。
- **Hook 失败不影响翻译**。非零退出、超时（30s）、命令不存在都只打一条警告，翻译结果照常落盘。
- **失败与中断也会触发**。这正是最该叫醒 Agent 的时刻——以前这两种情况一路抛到顶层，没有任何出口。
- Hook 命令只能来自 CLI 参数 / 环境变量 / 配置文件这三个你亲手填的来源，绝不从翻译内容或输入文件派生。

## 术语管理

固定术语确保特定词汇翻译一致。保存在项目目录 `.qmt/terms.csv`，无表头，每行 `源词,译词[,备注]`。第三列可选，两列老行和三列新行可以共存于同一个文件，升级不需要迁移。

```bash
# 添加术语
qmt terms add "React" "React"
qmt terms add "组件" "コンポーネント"

# 同一个源词有多个已批准译词时，用 -n 写清各自的使用语境（见「一词多译」）
qmt terms add "丹药" "단약" -n "物品栏用词"
qmt terms add "丹药" "영약" -n "修炼语境"

# 查看术语列表
qmt terms list

# 删除术语
qmt terms remove "React"

# 清空所有术语
qmt terms clear

# 从 CSV/TSV 文件批量导入
qmt terms import terms.csv

# 翻译时使用内联术语（不保存）
qmt "React组件开发" -t Japanese --terms "React:React,组件:コンポーネント"

# 对账上传到 AnyTrans 云端干预词库（详见「术语 RAG 模式」）
qmt terms push --dry-run
qmt terms push
qmt terms pull
```

> `terms import` **不同步向量索引**：批量导入的术语在 `qmt vectordb rebuild` 之前对本地语义匹配不可见。云端模式无此问题，`push` 后即刻可召回。

### 一词多译

同一个源词允许有多个已批准译词。这时该源词**整条撤出硬约束通道**：AnyTrans 的 `terminologies` 与 Qwen-MT 的 `terms` 都只有 src/tgt 两个字段，两条 src 相同、tgt 互斥的条目是受约束解码级的矛盾指令，模型必须违反其中一条。撤出后所有候选译词连同备注折进自由文本（AnyTrans 的 `domain_hint`、Qwen-MT 的 `domains`、通用大模型提示词里独立的一节），由模型按当前上下文选一个。

备注就是给模型的选择依据，也**只在这种情况下生效**。单译词术语写备注不会有任何效果——硬约束通道没有备注字段，把它渲染出来等于将一个确定的术语降级成软提示。`qmt terms list` 用 `!` 标出会走软裁决的源词。

两条边界：

- 备注是**本地独有**的。`TermEdit` 只有 src/tgt，`terms push` 推不上去；`terms pull --export` 写的是服务端快照，也只有两列。
- 内联 `--terms "源:译"` 表达不了备注。

缓存：改了冲突源词的备注会让相关行的键移动一次（定向 miss，重跑即可），其余备注编辑都不动键——细则见[「缓存」](#缓存)。

### 复合词与子词译法不一致

[一词多译](#一词多译)是同一个源词有多个译词。还有另一类歧义，源词**互不相同**，所以 `split_conflicts` 检测不到，任何代码路径都拦不住——只能写进领域提示词。

典型形态是复合条目的译词只覆盖了原文的一部分，而它内部的子词另有自己的条目：

```
秩序白银 → シルバー     秩序 → 秩序
荣耀黄金 → ゴールド     荣耀 → 栄光
永恒钻石 → ダイヤ       永恒 → 永遠
```

段位名实际是「前缀 + 金属 + 罗马数字」三段，`秩序白银 → シルバー` 给的只是金属段。模型看到长条目字面命中就照抄，译出 `シルバーV`——它没有违规，它在严格执行术语表。

处理办法是在项目级 `.qmt/domain.md` 里把这类条目**点名说清楚**：列出冲突的条目、说明长条目只覆盖哪一段、给出拼装后的正确译法。抽象规则（「术语要完整译出」）不管用，必须落到具体条目。

实测（qwen3.8-max，中→日，15 行段位名）：领域提示词点名说明后 4 次独立运行全部 15/15；对照（同一术语表、无这一节）平均 7.5/15 且波动很大（6–10）。500 行全量上召回后遵守率 91.0% → 93.7%，召回率不变（领域提示词不参与检索）。顺带把罗马数字被转成全角（`V` → `Ⅴ`）的 8 行压到 1 行。

## 翻译记忆

提供参考翻译对，帮助模型保持翻译风格一致。保存在 `.qmt/memory.csv`。

```bash
# 添加翻译记忆
qmt memory add "你好世界" "Hello World"

# 查看记忆列表
qmt memory list

# 删除记忆
qmt memory remove "你好世界"

# 清空所有记忆
qmt memory clear

# 从文件批量导入
qmt memory import memory.csv
```

记忆对所有后端生效，注入方式不同：默认的通用大模型后端把它渲染进 system prompt（见 [通用大模型后端](#通用大模型后端)），AnyTrans 映射为 `ext.examples` few-shot 示例，`qwen-mt-*` 映射为 `tm_list`。云端 RAG 模式下 AnyTrans 改为把记忆折叠进 `ext.domain_hint`（见下节）。

本地模式下术语与记忆共享 4000 字符的请求预算，超出时按「术语优先」顺序截断——术语是硬约束必须保留，记忆只是软参考。云端模式的记忆走 `domain_hint`（另有预算），不再与术语争抢这 4000 字符。

## 术语 RAG 模式

术语与翻译记忆的**召回方式**有两种模式，由 `--rag` 选择：

| 模式 | 术语来源 | 记忆来源 | 需要 DashScope Key |
|---|---|---|---|
| `local` | 本地 `terms.csv` + zvec HNSW 向量索引 | 本地 `memory.csv` + 向量索引 | 是（embedding） |
| `cloud` | AnyTrans 服务端干预词库（TermQuery 召回） | 折叠进 `ext.domain_hint`，词法匹配 | **否**（仅指召回；模型本身仍可能要，只有 `-m anytrans` 不要） |

默认跟随后端：`-m anytrans` 走 `cloud`，其余后端（含默认的 `qwen3.8-max`）走 `local`。`--rag`、`QMT_RAG_MODE`、`qmt config set --rag-mode` 可强制任一种。

> 解析出的模式是 `cloud` 不等于真的走云端——还有一道「必须先 push」的闸门，见下。

`--rag cloud -m qwen-mt-plus` 是合法组合——术语查 AnyTrans 词库、翻译走 DashScope。但记忆的 `domain_hint` 折叠只发生在 AnyTrans 的线上序列化层，所以这种组合下记忆仍按 DashScope 的方式注入。

### 云端模式的前提：必须先 push

`.qmt/` 在 `.gitignore` 里，记录 termId 的边车 `.qmt/terms.remote.json` 不随仓库走。因此有一道硬闸门：**解析出的云端作用域在边车里没有条目时，直接留在 local，不发任何探测请求**。

实际含义：只有跑过 `qmt terms push` 的那台机器才真正进入云端模式。同事克隆仓库后行为与升级前完全一致，不会每次翻译白花一个 TermQuery 往返加一条警告。接手一个已有云端词库的项目，先 `qmt terms pull` 把 termId 拉回本地。

作用域由四个字段界定，四者共同决定查的是哪个词库：`workspace_id` / `source_lang` / `target_lang` / `scene`。改动其中任一个都会指向另一个（可能是空的）词库。

### 上手

```bash
# 1. AnyTrans 凭证与业务空间（已有则跳过）
qmt config set --access-key-id <AK> --access-key-secret <SK> --workspace-id <WID>

# 2. 固定语向与场景。auto 不是语言代码，不能作为词库的键
qmt config set --rag-mode cloud --source-lang Chinese --target-lang Korean --scene mt-turbo

# 3. 预览差异，再对账上传
qmt terms push --dry-run
qmt terms push

# 4. 验证
qmt config show          # 看「RAG / 术语检索」段是否显示「云端可用」
qmt "攻击力提升" -v       # -v 会打印「术语检索: 云端词库 (scene=mt-turbo)」
```

`-v` 只确认走了云端通路，不列出召回到的具体术语。要看服务端到底持有什么，用 `qmt terms pull --export` 导出词库对照。

### 术语对账（push / pull）

`terms.csv` 是真源，云端是派生镜像。`push` 拿本地 CSV 与边车对账，产出 ADD / MODIFY / DELETE 三类变更，分批（默认 100 条一次 TermEdit）上传，**每批确认后立即落盘 termId**——Ctrl-C 不会丢掉已确认的部分。

```bash
qmt terms push --dry-run        # 只打印增/改/删计数，不发任何请求
qmt terms push -v               # 逐条列出变更的术语
qmt terms push --prune          # 同时删除云端有、本地 CSV 没有的术语
qmt terms push --chunk-size 50  # 调整单次 TermEdit 携带条数
qmt terms pull                  # 拉云端状态到 .qmt/terms.remote.json
qmt terms pull --export         # 另外导出云端词库为 terms.cloud.csv
```

三条安全约束：

- `pull` **从不写 `terms.csv`**。默认只更新边车，`--export` 也只生成新文件，绝不覆盖真源。
- 删除必须显式 `--prune`。清空本地 CSV 后直接 `push` 产生零条删除，防止顺手清空云端词库。
- 本地同一源词有多个译词时 `push` 折叠为末行胜出，warn 一次并列出这些源词——云端词库一个源词只允许一个译词。**只有 push 折叠**：翻译时所有候选仍一起交给模型裁决，见[「一词多译」](#一词多译)。

源语言为 `auto`（默认值）时 `push`/`pull` **硬失败**并点名修复命令：往 `auto` 这个键上写词库是不可恢复的垃圾数据。翻译时遇到 `auto` 则只是降级为 local——写坏数据不可逆，召回次优可逆。

### 云端模式的翻译记忆

AnyTrans 没有翻译记忆接口，所以云端模式把筛选出的记忆对折叠进 `ext.domain_hint`（引导语用英文，记忆对本身保持原语言），这是两个端点唯一共享的自由文本字段。[一词多译](#一词多译)的冲突块也用这个字段，但**两个模式都用**；记忆折叠仍然只在云端发生，本地模式有原生的 `ext.examples`。

云端模式没有 embedding 可用，记忆筛选是**零 API 调用**的词法匹配：子串包含（记忆源文出现在行文本里）+ CJK 二元组 Jaccard 重叠。确定性、可缓存，同一输入永远得到同一选择。

预算：冲突块和记忆块各有 1200 字符的独立上限，冲突块先扣费；整个 `domain_hint` 上限 3000 字符。触顶时**先砍折叠块，绝不砍你的 `domain.md` 正文**——正文每条请求都一样长，砍了会表现成「提示词随机失效」。一块放不下也不会把另一块带走。

### 降级链

云端模式任何一环失败都降级为本地向量检索，**各只提示一次**，绝不中断翻译：

| 触发条件 | 结果 |
|---|---|
| AnyTrans 凭证缺失 | 降级 local，提示一次 |
| 作用域不完整（缺 `workspace_id` / `source_lang` / `target_lang`） | 降级 local |
| 边车无此作用域条目 | 静默留在 local（不发请求，避免新克隆机器每条命令都警告） |
| TermQuery 调用数超过 200 上限 | 降级 local，不静默发数百个付费调用 |
| TermQuery 出错 / 超时 / 部分失败 | 丢弃整轮召回并降级 local |
| 云端词库空但本地 CSV 非空 | 降级 local（镜像是空的，不是没有术语） |

批量运行中降级是**永久**的：词库不可达、为空、或太大无法查询，在整个运行期间都不会变好，按块重试只是每次都白付一个往返去重新得到同一个答案。交互式模式下每轮重建，一次网络抖动不会拖垮整个会话。

### 排查入口：config show

`qmt config show` 的 **RAG / 术语检索** 段是唯一的诊断入口——降级本身是静默的（除了那一行提示），这里才能看出**为什么**：

```
RAG / 术语检索:
  rag_mode    = cloud
  生效模式    = cloud
  source_lang  = Chinese  来源: project
  target_lang  = Korean  来源: project
  scene        = mt-turbo  来源: default
  workspace_id = llm-xxxxxxxx
  状态: 云端可用，边车已记录 25452 条 termId
```

作用域不完整时会点名**具体卡在哪个字段**并给出确切的修复命令：

```
  状态: 云端 scope 不完整，翻译时将降级为本地向量检索
    缺 source_lang: -s/--source 指定源语言，或 qmt config set --source-lang Chinese
                    （"auto" 不是语言代码，不能作为词库的键）
    缺 workspace_id: --workspace-id 指定业务空间，或 qmt config set --workspace-id <ID>
```

### 缓存

`rag_mode` 参与翻译缓存键。服务端可能自行施加已注册的干预术语，所以同文本、同术语、同记忆的 cloud 请求与 local 请求是**不同的 API 载荷**，不能共用缓存条目。该字段仅在取值 `cloud` 时拼入键，因此升级不会让已有的 `cache.db` 失效。

术语哈希同样只把**真的会上线**的东西算进去：备注仅在它所属的源词确实有多个已批准译词时才拼入键。于是无备注的 `terms.csv` 与升级前逐字节同哈希；把备注写在单译词术语上也不动键——那种备注根本不渲染，载荷逐字节相同；而改了冲突源词的备注会移动一次键，让「冲突已存在 → 补备注 → 重跑批量」这个主用法不会命中旧缓存、备注永远不被行使。

```bash
qmt cache stats    # 条目数 / 上限 / 存储路径
qmt cache clear    # 清空（会先确认）
```

缓存键覆盖文本、语向、模型、领域、术语与记忆的内容哈希、`rag_mode`——但**不含提示词骨架的版本**。所以改了通用大模型后端的提示词（或升级到改了提示词的版本）之后，旧译文仍会命中，需要 `qmt cache clear`。

**端点也不在键里**，这是刻意的：同一个模型换个区域还是同一个模型，跨区域重跑命中缓存是省钱，不是错配。别来"修"它。

## 向量语义匹配

> 本节描述的机制属于 **local 模式**。云端模式的术语召回走 AnyTrans TermQuery、记忆召回走词法匹配，全程不调用 DashScope embedding。但本地索引仍然是云端降级时的兜底目标，所以 `--rag local` 覆盖和降级链都还需要它。

当术语或翻译记忆条目较多时（默认超过 20 条），QMT 自动启用向量语义匹配，从大量条目中智能筛选与当前翻译内容最相关的 Top-K 条（默认 10 条）传给模型，而不是全量传入。

底层使用 zvec 向量数据库（HNSW 索引 + 余弦距离）存储嵌入向量，通过 DashScope `qwen3.7-text-embedding-flash` 模型生成向量嵌入进行语义检索。向量索引以二进制格式存储在 `.qmt/terms.zvec/` 和 `.qmt/memory.zvec/`，首次翻译时自动构建，后续增量同步。

```bash
# 默认行为：术语/记忆超过 20 条时自动启用
qmt "进入副本后需要先组队" -t Korean -v

# 控制返回条数（默认 10）
qmt "进入副本" -t Korean --top-k 5

# 调整启用阈值（默认 20，设高则不启用）
qmt "进入副本" -t Korean --threshold 50

# 批量翻译时自动预嵌入所有源文本，逐行筛选最相关术语/记忆
qmt -B input.xlsx -t Korean -v
```

### --learn 自动学习

翻译结果可自动写回翻译记忆库，并即时同步向量索引：

```bash
# 单条翻译 + 自动学习
qmt "锻造装备需要金币" -t Korean --learn

# 批量翻译 + 批量学习（所有成功翻译自动写入记忆）
qmt -B input.csv -t Korean --learn

# 交互模式中切换学习
qmt -i -t Korean
# 输入 /learn 开启自动学习
```

## 向量索引管理

当术语库条目较多时（如 25000+），首次翻译需要嵌入所有术语会非常耗时。推荐提前构建向量索引：

```bash
# 查看当前索引状态
qmt vectordb status

# 从 CSV 重建全部向量索引（术语 + 翻译记忆）
qmt vectordb rebuild

# 清除所有向量索引（不影响 CSV 数据）
qmt vectordb clear
```

`rebuild` 会调用 DashScope embedding API 逐批嵌入所有术语/记忆，构建完成后后续翻译即可使用向量语义匹配。如果未预先构建索引且术语超过 500 条，翻译时会自动降级为截取前 N 条。

`status` 首行会报当前 RAG 模式。云端模式下 `rebuild` / `clear` / `query` **仍然可用**——`--rag local` 是合法的临时覆盖，它需要一个能用的索引，所以这里只报告模式、不禁用命令。

每个索引还会记下落盘时用的端点，与当前生效端点不一致时 `status` 多打一行：

```
    端点: https://dashscope.aliyuncs.com/compatible-mode/v1 (当前: https://dashscope-intl.aliyuncs.com/compatible-mode/v1) ⚠ 不一致，建议 rebuild
```

**这是警告，不是失败。** 索引本身不依赖端点，照样能查，真实风险只是命中率悄悄不对——为这个把翻译卡住不划算。翻译时同样只警告一次。但 `qwen3.7-text-embedding-flash` 是分区域上架的，新加坡端点没有它，切过去嵌入调用会直接 404，那是 API 层的失败、不走这条警告。对比之下**嵌入模型变更是硬失败**（报"嵌入模型已变更，请先运行: qmt vectordb rebuild"），因为不同模型的向量彼此无意义。manifest 早于端点档案功能时没有这一项，视为"历史/未知"，不告警。

## 领域提示

领域提示告诉模型翻译的专业领域和风格要求，支持两级存储：

- **项目级** — 保存在当前目录 `.qmt/domain.md`，优先级最高
- **全局级** — 保存在 `~/.qmt/domain.md`，作为默认值

```bash
# 设置项目级领域
qmt domain set "technology"

# 设置全局级领域
qmt domain set "medical translation, formal tone" --global

# 查看当前配置
qmt domain show

# 清除项目级领域
qmt domain clear

# 清除全局级领域
qmt domain clear --global
```

优先级：命令行 `-d` 参数 > 项目级 > 全局级。

翻译时自动加载已保存的领域提示，无需每次手动指定：

```bash
# 先设置领域
qmt domain set "game localization, martial arts world view"

# 后续翻译自动生效
qmt "仙缘副本" -t Korean
qmt -B game_strings.xlsx -t Korean
```

**分工。** qmt 内置的 system prompt 是语言无关的，只放通用纪律：术语逐字采用、记忆作参考、只输出译文。任何跟具体语言或具体项目绑定的规则都写在 `domain.md` 里——敬语体、数字与标点习惯、占位符处理方式，以及术语表条目互相矛盾时怎么裁决（见[「复合词与子词译法不一致」](#复合词与子词译法不一致)）。用户文本逐字插入，不会被再翻译一道。

## 交互式模式

```bash
qmt -i -t English
```

交互模式下可用命令：

| 命令 | 说明 |
|------|------|
| `/help` | 显示帮助 |
| `/target <lang>` | 切换目标语言 |
| `/source <lang>` | 切换源语言 |
| `/model <name>` | 切换模型，并重建客户端 (anytrans/plus/flash/lite/qwen3.8-max/llm:<名>) |
| `/endpoint <name>` | 切换端点档案，base URL 与该区域的 API Key 一起换；无参数列出当前端点与已存档案 |
| `/stream` | 切换流式输出 |
| `/domain <text>` | 设置领域提示 |
| `/domain save [global]` | 保存当前领域到项目/全局 |
| `/domain clear` | 清除当前领域 |
| `/agent <name>` | AnyTrans 专家角色 (game/medical/tech/...，clear 清除) |
| `/scene <name>` | AnyTrans 模型场景 (mt-plus/mt-turbo) |
| `/rag <mode>` | 术语检索模式 (local/cloud)，切换后立即重建检索上下文 |
| `/topk <n>` | 设置语义匹配 Top-K |
| `/learn` | 切换翻译记忆自动学习 |
| `/info` | 显示当前设置 |
| `exit` / `quit` | 退出 |

`/target` `/source` `/scene` `/model` `/rag` `/topk` `/endpoint` 都会改变术语与记忆的召回条件，因此每条命令执行后**立即重建检索上下文**——会话余下部分不会继续查询过期的语向或作用域。云端召回出错时同样按轮重建，一次网络抖动不会让整个会话都退化成无术语翻译。

`/model` 还会**重建翻译客户端并关闭旧的**。它此前只换掉模型名变量，请求仍旧走原来那个 client，新名字被静默忽略；三个后端之后这不只是"用错模型"，而是"用错凭证体系"（DashScope API Key vs AnyTrans AK/SK）。所以 `/model anytrans` 与 `/model qwen3.8-max` 之间的往返切换是真的换了后端。

`/endpoint` 换的不只是 URL，**Key 跟着一起换**——这是[端点档案](#端点档案多区域切换)存在的理由。会话内的翻译客户端、本地 RAG 的 embedding 调用、以及 `--learn` 的记忆回写全部改用新区域的 Key；只搬 URL 会得到一个用 A 区 Key 打 B 区 URL 的会话，而它的表现是几轮之后一个看不出因果的 401。切换失败（档案名不存在、Key 短了）时打一行原因并**留在原端点**，会话不受影响。`/info` 里能看到当前端点与掩码后的 Key。`-m anytrans` 下切换照样有效（本地 RAG 的 embedding 确实跟随），但 AnyTrans 自身仍钉在 `cn-beijing`。

## 完整参数一览

```
qmt [TEXT] [OPTIONS]

参数:
  TEXT                    待翻译文本

选项:
  -t, --target TEXT       目标语言 (默认 Chinese)
  -s, --source TEXT       源语言 (默认 auto)
  -m, --model TEXT        模型: anytrans / qwen-mt-plus|flash|lite / qwen3.8-max
                          / qwen3.7-plus / qwen3.8-flash / deepseek-v4-pro-0813
                          / kimi-k3 / llm:<任意百炼模型名>
                          (默认 qwen3.8-max；完整能力表见 qmt models)
  -f, --file PATH         翻译文件内容
  -d, --domain TEXT       领域提示
  -i, --interactive       交互式模式
  --stream                流式输出 (qwen-mt-flash/lite 与通用大模型支持)
  --terms TEXT            内联术语 (源:译,源:译)
  --terms-file PATH       术语文件 (CSV/TSV)
  --memory-file PATH      记忆文件 (CSV/TSV)
  -B, --batch PATH        批量翻译文件 (CSV/Excel)
  -O, --output PATH       批量翻译输出文件
  -C, --concurrency INT   批量翻译并发请求数 (默认 5, 范围 1-20)
  --batch-size INT        每批携带文本条数 (默认 10, 范围 1-50, 仅 anytrans)
  --batch-api             百炼批量推理: 半价, 但等待以小时计 (仅通用大模型 + CSV/TSV)
  --hook TEXT             批量终态时执行的 shell 命令 (唤醒外部 Agent，见「进度观察与 Hook 唤醒」)
  --no-header             CSV/Excel 无表头模式
  --resume                断点恢复
  --top-k INT             语义匹配返回条数 (默认 10)
  --threshold INT         语义匹配启用阈值 (默认 20)
  --learn                 翻译结果自动写入翻译记忆
  --agent TEXT            AnyTrans 专家角色 (仅 -m anytrans 生效)，与 -B 共用时禁用批量
  --scene TEXT            AnyTrans 模型场景 (mt-plus/mt-turbo，仅 -m anytrans 生效)
  --thinking / --no-thinking
                          思考模式 (仅通用大模型)。不传时按注册表决定
  --thinking-budget INT   思考 token 预算 (1-32768, 仅在思考开启时发送)
  --rag [local|cloud]     术语检索模式 (默认 -m anytrans 走 cloud、其余后端走 local)
  --workspace-id TEXT     AnyTrans 业务空间 ID
  --api-key TEXT          DashScope API Key
  --endpoint TEXT         本次请求的端点: 已存档案名 (qmt endpoint list) 或完整 URL。
                          档案自带该区域的 Key，URL 与 Key 一起切；优先级高于
                          QMT_BASE_URL 与持久化的生效档案。-m anytrans 不受影响
  -v, --verbose           显示详细信息
  --no-store              不加载本地术语/记忆库
  -h, --help              显示帮助
  -V, --version           显示版本
```

## 项目结构

```
src/qmt/
  cli.py              # CLI 入口与命令定义
  model_registry.py   # 模型名解析与后端路由 (llm: 前缀在此剥掉)
  backends.py         # 按后端构建客户端的工厂 + 各后端的 warn-once 提示
  anytrans_client.py  # AnyTrans 翻译客户端 (ROA 签名，推荐)
  client.py           # Qwen-MT API 客户端 (同步，OpenAI 兼容模式)
  llm_prompt.py       # 通用大模型提示词适配层 (纯函数，零 I/O)
  llm_client.py       # 通用大模型客户端 (同步，OpenAI SDK)
  async_client.py     # 异步客户端 (批量翻译用，含通用大模型后端)
  batch.py            # CSV/Excel 批量翻译 (并发 + 自适应限流)
  batch_inference.py  # 百炼批量推理 (--batch-api，上传 JSONL + 轮询)
  run_config.py       # RunConfig: 一次运行解析好的配置，在 CLI 边界构建一次后下发
  endpoint.py         # 端点档案: base_url 与该区域 API Key 的解析/校验/发布 (含模块级 holder)
  cache.py            # 翻译结果缓存 (SQLite)
  interactive.py      # 交互式 REPL
  matcher.py          # 向量语义匹配编排层
  rag.py              # RAG 作用域与 ContextProvider 抽象 (local/cloud 模式选择)
  cloud_terms.py      # AnyTrans 云端干预词库客户端 (TermQuery / TermEdit)
  term_sync.py        # terms.csv 与云端词库的对账 (push/pull，无 SDK 依赖)
  embedding.py        # DashScope qwen3.7-text-embedding-flash 嵌入
  vectorstore.py      # 向量索引存储 (zvec HNSW)
  store.py            # 本地持久化存储 (术语/记忆/领域/凭证，带文件锁)
  filelock.py         # 跨进程文件锁 (fcntl)
  models.py           # 数据模型 + 一词多译的冲突裁决 (拆分/渲染/切片)
  parsers.py          # 输入解析
  formatters.py       # 终端输出格式化
  constants.py        # 常量定义
  exceptions.py       # 自定义异常
  notify.py           # 邮件通知
  runstate.py         # 批量运行状态落盘 (.qmt/runs/，供 qmt status 读取)
  hooks.py            # 终态 Hook: 执行用户配置的 shell 命令唤醒外部 Agent
```

## 开发

```bash
# 安装开发依赖
pip install -e ".[dev]"

# 代码检查（门禁；规则集在 pyproject.toml 里钉死）
ruff check src/qmt/ tests/

# 运行测试
pytest
```

不要对整棵树跑 `ruff format`：这棵树从来没被 format 过，一次会重写 53 个文件里的 36 个，真实改动就埋进无关 diff 里了。只手工格式化自己碰过的行，向周围风格看齐。

## License

Apache-2.0
