跳至主要内容

Ollama:用本地 LLM 翻譯字幕

前情提要

SRT 字幕怎麼來,可參考「Whisper:影音轉錄字幕檔」筆記。

這篇記錄怎麼用 Ollama 跑本地模型,把一支日文 SRT 字幕翻成通順的中文。

以前我都丟到 translatesubtitles.co 套 Google 翻譯,一秒完成,但翻得不精準、也不吃上下文。

改用本地 LLM 後品質好很多,關鍵在三件事:提示詞要寫好、要分批、要有重試機制。

環境準備

開始前需要兩樣東西:

  1. Ollama:在自己電腦上跑本地模型的工具,到官網下載安裝即可。
  2. 一個語言模型:裝好 Ollama 後,在終端機執行 ollama pull qwen3:4b 把模型抓下來(也可以換成別的,用 ollama list 可看已安裝的模型)。
資訊

不確定自己的電腦能跑多大的模型?可以參考「Can I Run AI locally?」網站來挑。

設計重點

1. 提示詞要寫好

字幕翻譯最大的麻煩是「對齊」,小模型常會偷偷合併、拆分或漏句,導致譯文對不回原本的時間軸。

解法是給每句一個 [編號],並在系統提示裡把規則講死:

BATCH_SYSTEM = (
"你是專業的日中字幕譯者。使用者會給你多句日文字幕。"
"標示為「(參考,勿翻)」的句子只供你理解上下文,「絕對不要翻譯也不要輸出它們」。"
"只有前面有 [編號] 的句子才需要翻成通順、易懂的繁體中文,並用「完全相同的編號」逐句輸出。"
"嚴格要求:"
"1) 輸出格式必須是每行一句:[編號] 中文翻譯。"
"2) 只輸出有編號的那些句子,編號數量、順序必須和輸入的編號一模一樣,不可合併、不可拆分、不可漏句。"
"3) 不要輸出參考句、不要輸出日文原文、不要解釋、不要多餘內容。"
"4) 口語自然、貼近字幕用語。"
" /no_think"
)

收到回覆後用正規表達式把 [編號] 譯文 拆回來,編號對得上才算數:

m = re.match(r"\s*\[(\d+)\]\s*(.*)", line)

2. 要分批(可帶上下文)

一次送一句太慢、又完全沒有上下文;一次送整部又會超出小模型能穩定處理的長度。

折衷是一次送一批帶編號的句子,並在批次前後各附幾句「參考、勿翻」的句子補接縫上下文:

BATCH_SIZE = 20          # 一批幾句(越大越快、上下文越多,但對齊風險略升)
CONTEXT_OVERLAP = 3 # 每批額外附上前/後各幾句當參考,補接縫上下文

3. 要有重試機制

就算提示寫得再嚴,小模型偶爾還是會對不上。這裡用兩層保險:

  1. 補漏重試:把這批「沒對上的那幾句」湊成小批再送,不逐句慢補。
  2. 斷點續傳:每翻完一批就把進度寫進 progress.json(以 SRT 編號為 KEY),同時覆蓋整支 SRT。重試幾次仍補不到的就留著不翻、保留原文,下次「再跑一次同樣指令」會自動跳過已完成、只補沒翻到的。
MAX_BATCH_RETRY = 2      # 對齊失敗時,把缺的句子湊成小批補翻幾次

因為每批都即時存檔,所以翻譯過程中隨時中斷都不會白做,而且任何時候打開輸出檔都是一支完整可播的字幕(沒翻到的部分顯示日文原文)。

整套設計好之後,從影片到中文字幕就是兩條指令的事,翻譯品質遠勝 Google 翻譯。

補充:換成線上 LLM 呢?

如果改用 ChatGPT、Gemini 這類線上大模型來翻,整套思路其實一樣,只是因為大模型「聽話」很多,可以大幅簡化:

  • 提示詞:核心要求不變(逐句對編號、保留時間軸、口語自然),但本地那套層層防呆(「編號絕對不可合併拆分漏句」「參考、勿翻」)是用來補小模型的對齊能力差,大模型多半用不上,寫簡短一點就好。
  • 分批:大模型的上下文很長,一支影片的字幕常常一次就塞得下,批次可以放到幾百句、批數大幅變少。但別期待「一批搞定整部」,輸出行數一多,模型還是可能中途偷懶、合併或截斷,所以還是會分批,只是批次大得多。
  • 重試:大模型對齊失敗率低很多,但不是零。差別在通常是手動回一句「第幾到第幾句漏了,補一下」,而不像本地要寫自動補漏與斷點續傳。

一句話:框架同形,但可以瘦身。本地這套之所以工程味這麼重,多半是在補小模型的不可靠;換成線上大模型,這些保險機制大多能拿掉。代價則是內容容易被審查、字幕也得上傳第三方。

完整程式碼

上面拆開講的是設計重點,這裡附上能直接跑的完整腳本「當參考」。

以下這個是 Claude 寫的,建議大家可以照自己的需求修改或重新生成。

translate_srt_batch.py(點一下展開)
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
SRT 日翻中(批次版,兩全其美)

做法:一次送一「批」帶編號的句子給模型(有上下文、又快),
用編號 [n] 把譯文對回每一句 → 仍輸出逐筆字幕、時間軸完全不動。
若某批編號對不上(小模型偶爾合併/漏句),把「缺的那幾句」湊成小批「補漏重試」
(不逐句慢補);重試幾次仍補不到的,留著不翻,下次重跑時靠續傳自動補。

輸入任何 .srt(含 transcribe 產生的 input.jp.srt);輸出中文成品 input.zh.srt,原文不會被覆蓋。

用法:
python translate_srt_batch.py # 列出資料夾內的 .srt 讓你選
python translate_srt_batch.py input.srt # 直接指定
python translate_srt_batch.py input.srt --bilingual # 中日雙語 → input.zh-ja.srt
python translate_srt_batch.py input.srt --batches 3 # 只翻前 3 批(測試用)

需求:
ollama pull qwen3:4b (或改下方 MODEL)
"""

import sys
import re
import json
import urllib.request
from pathlib import Path

# Windows 終端機預設 cp950 無法顯示部分日文漢字,強制 stdout 用 UTF-8 避免崩潰
try:
sys.stdout.reconfigure(encoding="utf-8")
except Exception:
pass

# ── 設定 ──────────────────────────────────────────────
MODEL = "qwen3:4b" # 改成你 `ollama list` 看到的名稱
OLLAMA_URL = "http://localhost:11434/api/chat"
SUBTITLE_EXTS = {".srt"}
BATCH_SIZE = 20 # 一批幾句(越大越快、上下文越多,但對齊風險略升)
CONTEXT_OVERLAP = 3 # 每批額外附上前/後各幾句當「參考、勿翻」,補接縫上下文
MAX_BATCH_RETRY = 2 # 對齊失敗時,把缺的句子湊成小批補翻幾次(之後留待續傳)

BATCH_SYSTEM = (
"你是專業的日中字幕譯者。使用者會給你多句日文字幕。"
"標示為「(參考,勿翻)」的句子只供你理解上下文,「絕對不要翻譯也不要輸出它們」。"
"只有前面有 [編號] 的句子才需要翻成通順、易懂的繁體中文,並用「完全相同的編號」逐句輸出。"
"嚴格要求:"
"1) 輸出格式必須是每行一句:[編號] 中文翻譯。"
"2) 只輸出有編號的那些句子,編號數量、順序必須和輸入的編號一模一樣,不可合併、不可拆分、不可漏句。"
"3) 不要輸出參考句、不要輸出日文原文、不要解釋、不要多餘內容。"
"4) 口語自然、貼近字幕用語。"
" /no_think"
)
# ─────────────────────────────────────────────────────


def is_output(name: str) -> bool:
"""判斷是否為本程式自己產生的檔(中文成品/過程/測試),列檔時排除避免重複翻譯。"""
n = name.lower()
return n.endswith((".zh.srt", ".zh-ja.srt")) or ".partial." in n or ".test." in n


def choose_srt(base: Path) -> Path:
files = sorted(p for p in base.iterdir()
if p.is_file() and p.suffix.lower() == ".srt"
and not is_output(p.name))
if not files:
sys.exit(f"資料夾裡找不到可翻譯的 .srt 來源檔:{base}")
print("\n請選擇要翻譯的字幕(.srt):")
for i, p in enumerate(files, start=1):
print(f" [{i}] {p.name}")
while True:
c = input("\n輸入號碼 (Enter 取消): ").strip()
if c == "":
sys.exit("已取消。")
if c.isdigit() and 1 <= int(c) <= len(files):
return files[int(c) - 1]
print("號碼無效,請重新輸入。")


def parse_srt(text: str):
blocks = re.split(r"\r?\n\r?\n", text.strip())
parsed = []
for blk in blocks:
rows = blk.splitlines()
if len(rows) < 2:
continue
idx = rows[0].strip()
ts = rows[1].strip()
body = " ".join(r.strip() for r in rows[2:]).strip()
parsed.append((idx, ts, body))
return parsed


def write_srt(out_path, blocks, done, bilingual):
"""從 blocks + 進度 done 重組整支 SRT 並覆蓋寫出。
沒翻到的句子保留原文。每批翻完都呼叫一次 → 過程中隨時是完整可播的字幕。"""
with open(out_path, "w", encoding="utf-8") as f:
for (idx, ts, ja) in blocks:
zh = done.get(idx, ja)
f.write(f"{idx}\n{ts}\n")
if bilingual:
f.write(f"{zh}\n{ja}\n\n")
else:
f.write(f"{zh}\n\n")


def clean_think(s: str) -> str:
s = re.sub(r"<think>.*?</think>", "", s, flags=re.DOTALL).strip()
s = re.sub(r"^.*?</think>", "", s, flags=re.DOTALL).strip()
return s.strip()


def call_ollama(system: str, user: str, timeout=600) -> str:
payload = {
"model": MODEL,
"messages": [
{"role": "system", "content": system},
{"role": "user", "content": user},
],
"stream": False,
"think": False,
"options": {"temperature": 0.3},
}
data = json.dumps(payload).encode("utf-8")
req = urllib.request.Request(
OLLAMA_URL, data=data, headers={"Content-Type": "application/json"}
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
result = json.loads(resp.read().decode("utf-8"))
return clean_think(result["message"]["content"])


def translate_batch(texts, before=None, after=None):
"""送一批,回傳 dict {本批內序號(1起): 中文}。失敗的序號不在 dict 裡。
before/after:前後批的參考句(只當上下文、不翻)。"""
parts = []
for t in (before or []):
parts.append(f"(參考,勿翻) {t}")
for i, t in enumerate(texts, start=1):
parts.append(f"[{i}] {t}")
for t in (after or []):
parts.append(f"(參考,勿翻) {t}")
out = call_ollama(BATCH_SYSTEM, "\n".join(parts))
result = {}
for line in out.splitlines():
m = re.match(r"\s*\[(\d+)\]\s*(.*)", line)
if m:
n = int(m.group(1))
zh = m.group(2).strip().strip('「」"“”').strip()
if zh:
result[n] = zh
return result


def main():
base = Path(__file__).resolve().parent
argv = sys.argv[1:]
bilingual = "--bilingual" in argv

# 解析 --batches N(只翻前 N 批,測試用)
max_batches = None
if "--batches" in argv:
bi = argv.index("--batches")
try:
max_batches = int(argv[bi + 1])
except (IndexError, ValueError):
sys.exit("--batches 後面要接數字,例如 --batches 3")

# 過濾掉旗標與其值,剩下的視為檔名
args = []
skip = False
for i, a in enumerate(argv):
if skip:
skip = False
continue
if a == "--batches":
skip = True
continue
if a.startswith("--"):
continue
args.append(a)

if args:
src = Path(args[0])
if not src.is_absolute():
src = base / src
else:
src = choose_srt(base)
if not src.exists():
sys.exit(f"檔案不存在:{src}")

# 從來源檔取出乾淨主檔名:input.jp.srt → input、input.srt → input
if src.name.lower().endswith(".jp.srt"):
base_name = src.name[:-len(".jp.srt")]
else:
base_name = src.stem

# 沒用 --batches 指定時,互動詢問要不要只測前 N 批
if max_batches is None:
c = input("測試批數(直接 Enter = 翻全部): ").strip()
if c:
if c.isdigit() and int(c) > 0:
max_batches = int(c)
else:
sys.exit("請輸入正整數,或直接 Enter 翻全部。")

suffix = ".zh-ja.srt" if bilingual else ".zh.srt" # 加 .zh 後綴,絕不覆蓋輸入原文
final_path = src.with_name(base_name + suffix) # 翻完才產生的乾淨成品
partial_path = src.with_name(base_name + ".partial" + suffix) # 過程中持續覆蓋的暫時檔
out_path = partial_path # 翻譯途中都寫 partial

print(f"[輸入] {src}")
print(f"[過程] {partial_path}")
print(f"[成品] {final_path}")
print(f"[模型] {MODEL}(批次={BATCH_SIZE},雙語={bilingual})\n")

blocks = parse_srt(src.read_text(encoding="utf-8-sig"))
full_total = len(blocks)

# 測試模式:只取前 N 批,過程/成品都用同一個 test 檔(本來就是不完整的)
if max_batches is not None:
blocks = blocks[: max_batches * BATCH_SIZE]
out_path = final_path = partial_path = src.with_name(base_name + ".test" + suffix)
print(f"[測試] 只翻前 {max_batches} 批,輸出 {out_path}")

total = len(blocks)
n_batches = (total + BATCH_SIZE - 1) // BATCH_SIZE
print(f"[解析] 全片共 {full_total} 句,本次處理 {total} 句({n_batches} 批)…\n")

# ── 斷點續傳:載入既有進度(以 SRT 編號為 key)──
progress_path = src.with_name(base_name + ".progress.json")
done = {}
if progress_path.exists():
try:
done = json.loads(progress_path.read_text(encoding="utf-8"))
except Exception:
done = {}
if done:
print(f"[續傳] 載入既有進度:已完成 {len(done)} 句,這些會直接略過。\n")

def save_progress():
progress_path.write_text(
json.dumps(done, ensure_ascii=False), encoding="utf-8")

# 一開機就先輸出一次:即使還沒翻任何一批,也立刻有完整可播的 SRT
# (已續傳的句子用中文,其餘保留原文)
write_srt(out_path, blocks, done, bilingual)

fail_log = [] # 本次對齊失敗、需逐句補翻的紀錄

for start in range(0, total, BATCH_SIZE):
chunk = blocks[start:start + BATCH_SIZE]
bnum = start // BATCH_SIZE + 1

# 只挑這批「還沒翻過」的句子(依 SRT 編號判斷)
undone = [(idx, ts, ja) for (idx, ts, ja) in chunk if idx not in done]
if not undone:
print(f" ⏭ 批 {bnum}/{n_batches} 已完成,略過", flush=True)
continue

# 接縫用的前/後參考句(取自整份 blocks,含被截斷範圍外的句子)
before = [b[2] for b in blocks[max(0, start - CONTEXT_OVERLAP):start]]
after = [b[2] for b in blocks[start + BATCH_SIZE:start + BATCH_SIZE + CONTEXT_OVERLAP]]

# 補漏重試:缺的句子湊成小批再送,不逐句慢補
pending = list(undone) # [(idx, ts, ja), ...] 還沒翻成功的
sent = len(pending)
for attempt in range(MAX_BATCH_RETRY + 1):
if not pending:
break
texts = [p[2] for p in pending]
try:
got = translate_batch(texts, before, after)
except Exception as e:
print(f" ⚠ 批 {bnum}/{n_batches} 請求失敗:{e}", flush=True)
got = {}

still = []
for k, (idx, ts, ja) in enumerate(pending, start=1):
if k in got:
done[idx] = got[k]
else:
still.append((idx, ts, ja))

if attempt == 0:
if still:
print(f" ⚠ 批 {bnum}/{n_batches} 對齊失敗:送 {sent} 句、"
f"對上 {sent - len(still)} 句、缺 {len(still)} 句 → 補漏重試…",
flush=True)
else:
print(f" ✓ 批 {bnum}/{n_batches} 全部對齊({sent} 句)", flush=True)
else:
if still:
print(f" ↻ 重試 {attempt}:仍缺 {len(still)} 句", flush=True)
else:
print(f" ↻ 重試 {attempt}:已全部補齊", flush=True)
pending = still

# 重試後仍補不到的:留著不翻(輸出保留原文),下次重跑靠續傳補
for (idx, ts, ja) in pending:
print(f" ✗ SRT#{idx} 重試 {MAX_BATCH_RETRY} 次仍未對齊,留待下次續傳",
flush=True)
fail_log.append(f"[批{bnum}] SRT#{idx}(重試後仍未翻)\n 原文: {ja}")

save_progress() # 存進度 JSON → 隨時中斷都能續傳
write_srt(out_path, blocks, done, bilingual) # 同時覆蓋整支 SRT → 過程中即可播放

# 寫出失敗 log:這些是「重試後仍未翻」的句子,下次重跑會自動再試
log_path = src.with_name(base_name + ".translate.log")
if fail_log:
header = (f"重試後仍未翻紀錄 檔案={src.name} 模型={MODEL} "
f"BATCH_SIZE={BATCH_SIZE} MAX_BATCH_RETRY={MAX_BATCH_RETRY}\n"
f"共 {len(fail_log)} 句仍未翻(已保留原文,下次重跑會自動補):\n"
+ "=" * 50 + "\n\n")
log_path.write_text(header + "\n\n".join(fail_log), encoding="utf-8")
print(f"\n[LOG] 本次有 {len(fail_log)} 句重試後仍未翻,明細見:{log_path}")
print(" → 直接「再跑一次同樣指令」即可自動補這些句子。")
else:
print("\n[LOG] 本次處理的句子全部翻譯成功。")

# 收尾:輸出乾淨成品 input.zh.srt,並清掉過程用的 partial 檔
write_srt(final_path, blocks, done, bilingual)
if partial_path != final_path and partial_path.exists():
try:
partial_path.unlink()
except OSError:
pass

print(f"\n[完成] 已輸出成品:{final_path}")
print(f"[續傳] 進度檔:{progress_path}(整支翻完、確認無誤後可自行刪除)")


if __name__ == "__main__":
main()