docs(readme): 重写'单篇文章的一生'数据流章节
旧版只画了一个简单流程图,信息过时(只提腾讯 TMT + Agnes,没提 spark/zhipu/normalize)。
新章节:
- ASCII 流程图:APScheduler 启动 3 个常驻 loop(translation_loop / enrichment_loop),
全部经 articles 表 → /api/v1/articles/{id} → 前端
- T+0s~T+任意 时间线:每个阶段约几秒,清晰标出 spark/zhipu/tencent 引擎选择点
- 字段生命周期表:7 阶段 × 7 字段(翻译/4 LLM 状态),列 NULL/n/a/ok 真实状态
- 翻译引擎优先级表:spark → zhipu → tencent TMT → tencent_maas → agnes → local,
6 个引擎 + 配置/默认 model/配额/失败处理
- LLM 智能增强流程图:4 任务(classify/format/image/commentary)独立 try/except
+ 限速(LlmClient 内部 sem + enrichment_loop 外层 Semaphore(3))
- 故障模式速查表:6 个常见 case(pending 停滞 / 单项 failed / 401 / worker loop 没起
/ RSS 抓不到),给排查命令和修复方向
目的:让看 README 的人能一眼看出当前 6 翻译引擎链路 + 4 LLM 任务是不是真在工作。
便于在交接 / 出问题时,新人按文档直接对线。
This commit is contained in:
178
README.md
178
README.md
@@ -92,24 +92,172 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
**数据流**(单篇文章的一生):
|
**数据流**(单篇文章的一生):
|
||||||
|
|
||||||
|
下面这张图是当前生产实际链路。**抓**(RSS scheduler) → **翻**(translation_loop) → **增强**(enrichment_loop) → **展示**(/api/v1/articles/{id})。
|
||||||
|
|
||||||
```
|
```
|
||||||
RSS Feed → feedparser → FetchedItem
|
┌───────────────────────────────────────────────────────────────────────┐
|
||||||
↓
|
│ APScheduler 1 篇/秒(由 `backend/app/workers/__main__.py` 启动) │
|
||||||
url_hash = SHA1(url) + ON CONFLICT DO NOTHING ← 去重
|
├───────────────────────────────────────────────────────────────────────┤
|
||||||
↓ (新文章入库,translation_status=pending)
|
│ │
|
||||||
↓
|
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │
|
||||||
[translation_loop] 1篇/秒 Semaphore(1)
|
│ │ translation_ │ │ translation_ │ │ enrichment_ │ │
|
||||||
↓ 调 腾讯 TMT → body_zh_text/html
|
│ │ loop │ │ loop │ │ loop │ │
|
||||||
↓ status: pending → ok
|
│ │ 1 篇/秒 │ │ 独立(不并发抓) │ │ 2 秒/轮 + 8 并发│ │
|
||||||
↓
|
│ │ │ │ │ │ SELECT 待增强 │ │
|
||||||
[enrichment_loop] 扫描 *_status=pending 的已译文章
|
│ └────────────────┘ └────────────────┘ └────────────────┘ │
|
||||||
↓ 调 Agnes LLM: 排版 → 分类 → 插图 → 点评
|
└───────────────────────────────────────────────────────────────────────┘
|
||||||
↓ 4 任务独立 try/except,共享 chat_sem + image_sem 限速
|
▲ ▲ ▲
|
||||||
↓ status: ok / failed
|
│ │ │
|
||||||
↓
|
│ fetch_one_source │ translate_article │ enrich_article
|
||||||
[文章详情接口] 原文 + 译文 + AI 排版版 + 分类 + 插图 + 点评 全部展示
|
│ (RSS 抓取) │ (spark/zhipu/tencent) │ (Agnes 4 任务)
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
┌───────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ articles 表 │
|
||||||
|
│ 24 个核心字段 —— 见下方"字段生命周期"表 │
|
||||||
|
└───────────────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
|
下面用一条时间线 + 字段状态表,把"一篇文章从 RSS 出现到用户在前端看到"的全部状态变化说清楚。
|
||||||
|
|
||||||
|
### 时间线(典型情况,2026-06 链路)
|
||||||
|
|
||||||
|
```
|
||||||
|
T+0s RSS 抓取(每 30~60 分钟一次,按 source 配置)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
T+1s feedparser 解析 → 提取 url / title / body_text / lang_src
|
||||||
|
│ url_hash = SHA1(url)
|
||||||
|
│ INSERT ... ON CONFLICT (url_hash) DO NOTHING ← 去重
|
||||||
|
│
|
||||||
|
▼ 新行落地:
|
||||||
|
│ translation_status = 'pending'
|
||||||
|
│ format_status = 'n/a'
|
||||||
|
│ classify_status = 'n/a'
|
||||||
|
│ image_ai_status = 'n/a'
|
||||||
|
│ commentary_status = 'n/a'
|
||||||
|
│ category = NULL
|
||||||
|
│ title_zh / body_zh_* = NULL
|
||||||
|
│ fetched_at = now()
|
||||||
|
|
||||||
|
T+5~30s translation_loop 扫到这行
|
||||||
|
│ pipeline.translate_article(id)
|
||||||
|
│
|
||||||
|
├── 选引擎(service.py,优先级降序):
|
||||||
|
│ 1) spark ← Spark Lite (免费,默认)
|
||||||
|
│ 2) zhipu ← GLM-4-Flash (免费,2.3s/篇)
|
||||||
|
│ 3) tencent ← 腾讯云 TMT (按月配额;en-gb 会先 normalize)
|
||||||
|
│ 4) tencent_maas / agnes / local (备用,通常不用)
|
||||||
|
│
|
||||||
|
├── 翻译 title + body(按 4500 字符切 chunk)
|
||||||
|
│ 每个 chunk 走同一条引擎链
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
T+10~60s 翻译完成,严格 status 判定(`pipeline.py:197-212`):
|
||||||
|
│ 1) 检查 AuthFailure / TMT error marker
|
||||||
|
│ 2) 检查 body 是否等于原文(未翻译)
|
||||||
|
│ 3) 以上任一命中 → translation_status = 'failed'
|
||||||
|
│ 4) 否则 status = 'ok' 或 'partial'
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
T+15~30s enrichment_loop 扫到这行(translation_status='ok')
|
||||||
|
│ pipeline.enrich_article(id)
|
||||||
|
│
|
||||||
|
├── 4 任务(独立 try/except,任一失败不阻塞其他):
|
||||||
|
│ 1) classify ← LLM 返回 {"categories":[...]} → category 字段(逗号分隔多标签)
|
||||||
|
│ 2) format ← LLM 重排 → body_zh_formatted(含固定 CSS 容器)
|
||||||
|
│ 3) image ← 文生图,prompt 用正文第一段
|
||||||
|
│ 4) commentary ← LLM 点评
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
T+30~90s 4 任务全部跑完
|
||||||
|
│ status 分别落到: ok / failed / pending
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
T+任意 前端拉 /api/v1/articles/{id}
|
||||||
|
│ 展示:原文 + 译文(LLM 排版版) + 分类 + 插图 + 点评
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
用户看到完整文章
|
||||||
|
```
|
||||||
|
|
||||||
|
### 字段生命周期表(`articles` 表)
|
||||||
|
|
||||||
|
> 每一行是**一篇文章在某个时间点**的字段状态快照。**√** = 字段已填,NULL/`'n/a'`/`'pending'` 表示还没走到。
|
||||||
|
|
||||||
|
| 阶段 | translation_status | title_zh | body_zh_text | body_zh_formatted | image_ai_url | category | commentary | *_status (4 个 LLM) |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| **抓取后** | `pending` | NULL | NULL | NULL | NULL | NULL | NULL | `n/a` `n/a` `n/a` `n/a` |
|
||||||
|
| **翻译中** | `pending` | NULL | NULL | NULL | NULL | NULL | NULL | `n/a` `n/a` `n/a` `n/a` |
|
||||||
|
| **翻译完成** | `ok` | √ | √ | NULL | NULL | NULL | NULL | `n/a` `n/a` `n/a` `n/a` |
|
||||||
|
| **classify 完** | `ok` | √ | √ | NULL | NULL | √ | NULL | `n/a` `ok` `n/a` `n/a` |
|
||||||
|
| **format 完** | `ok` | √ | √ | √ | NULL | √ | NULL | `ok` `ok` `n/a` `n/a` |
|
||||||
|
| **image 完** | `ok` | √ | √ | √ | √ | √ | NULL | `ok` `ok` `ok` `n/a` |
|
||||||
|
| **commentary 完** | `ok` | √ | √ | √ | √ | √ | √ | `ok` `ok` `ok` `ok` |
|
||||||
|
|
||||||
|
> **状态值含义**:
|
||||||
|
> - `n/a` — 默认值,从未跑过 enrich
|
||||||
|
> - `pending` — 任务入队但还没处理
|
||||||
|
> - `ok` — 成功
|
||||||
|
> - `failed` — 该任务失败(其他任务继续)
|
||||||
|
|
||||||
|
### 翻译引擎优先级
|
||||||
|
|
||||||
|
> 降序排列,前者不可用时降级后者。完整逻辑见 `backend/app/services/translation/service.py`。
|
||||||
|
|
||||||
|
| 序位 | 引擎 | 配置 | 默认 model | 配额/限速 | 失败处理 |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| 1 | **spark** (Spark Lite) | `SPARK_API_PASSWORD` | `lite` | Lite 免费(企业级按 token) | 失败 → zhipu |
|
||||||
|
| 2 | **zhipu** (GLM-4-Flash) | `ZHIPU_API_KEY` | `glm-4-flash` | 免费(并发有限流) | 失败 → tencent |
|
||||||
|
| 3 | **tencent** (腾讯云 TMT) | `TENCENTCLOUD_SECRET_ID/SECRET` | n/a | 月 500 万字符(可调) | 配额满 → maas |
|
||||||
|
| 4 | tencent_maas (腾讯 MaaS u2) | `TENCENT_MAAS_API_KEY` | `u2` | 无配额 | 失败 → agnes |
|
||||||
|
| 5 | agnes (通用 LLM) | `AGNES_API_KEY` | n/a | 按 token | 失败 → local |
|
||||||
|
| 6 | local (本地 NLLB-200) | `LOCAL_TRANSLATE_ENABLED=true` | `nllb-200-distilled-600M` | 本地 CPU 推理 | 全部失败 → skip |
|
||||||
|
|
||||||
|
**关键细节**:
|
||||||
|
- **en-gb / zh-cn / ja-jp** 这类 BCP-47 区域码会被 `tencent.py:_normalize_lang` 切成主语言(`en` / `zh` / `ja`),否则 TMT 直接 400 报错。
|
||||||
|
- **缓存**: `translation:cache:{sha1(src|tgt|text)}` 在 Redis 存 30 天;同篇同 src/tgt 不重译。
|
||||||
|
- **配额**: `translation:month:YYYYMM` Redis 累加,腾讯 TMT 用满后自动切 maas。
|
||||||
|
|
||||||
|
### LLM 智能增强(4 任务并发限速)
|
||||||
|
|
||||||
|
> 入口 `backend/app/services/llm/enrichment.py`,常驻循环见 `enrichment_loop()`。
|
||||||
|
|
||||||
|
```
|
||||||
|
enrich_article(id):
|
||||||
|
┌─ classify → LlmClient.chat(llm 0.2) → 解析 {"categories":[…]} → category 字段
|
||||||
|
├─ format → LlmClient.chat(llm 0.3) → 排版后 HTML, 包到固定 CSS div
|
||||||
|
├─ image → LlmClient.generate_image(...) → URL 写到 image_ai_url
|
||||||
|
└─ commentary → LlmClient.chat(llm 0.6) → 200 字中文点评
|
||||||
|
```
|
||||||
|
|
||||||
|
**4 任务独立 try/except** — 任一失败只标该任务 `status=failed`,其他继续。最终落库结果示例:
|
||||||
|
```json
|
||||||
|
{"format": "ok", "classify": "ok", "image": "ok", "commentary": "ok"}
|
||||||
|
{"format": "ok", "classify": "failed:RuntimeError", "image": "ok", "commentary": "ok"}
|
||||||
|
```
|
||||||
|
|
||||||
|
**限速**:
|
||||||
|
- LlmClient 内部 `chat_sem + image_sem`(各 1 并发),`interval_sec=2.0` 每次调用后等 2s
|
||||||
|
- enrichment_loop 并发 8 篇(`ENRICHMENT_BATCH_SIZE=8`),`Semaphore(3)` 再降一次并发上限(防 Agnes 限流)
|
||||||
|
|
||||||
|
**状态健康度参考**:
|
||||||
|
- 174/174/174/174 = 100% OK(理想)
|
||||||
|
- 0 failed 是健康线
|
||||||
|
- 翻译后 30 秒内 enrich 入队是正常时延
|
||||||
|
|
||||||
|
### 故障模式速查
|
||||||
|
|
||||||
|
| 现象 | 排查 | 修复 |
|
||||||
|
|---|---|---|
|
||||||
|
| 文章停在 `pending` | `docker compose exec -T worker python -c "from app.config import settings; print(settings.tencentcloud_secret_id)"` 确认 TENCENT 配了;lang_src 是否带区域后缀 | en-gb 已被 normalize 兜底;真没 TENCENT key 走 spark/zhipu |
|
||||||
|
| `format_status=failed` 但其他 3 项 OK | `docker compose logs worker \| grep "enrich format failed"` 看异常 | 多数是 prompt 模板里 `{}` 没转义(`enrich_article` 内置 `_safe_format` 兜底) |
|
||||||
|
| 大量文章停在 `n/a n/a n/a n/a` | `curl /admin/health` 看 LLM 状态;`.env` 里 `AGNES_API_KEY` 是否过期 | 换新 key → `docker compose up -d worker` |
|
||||||
|
| 翻译报 `LLM 401` | `curl -X POST apihub.agnes-ai.com/v1/chat/completions -H "Authorization: Bearer $AGNES_API_KEY"` 单测 | Agnes key 失效,控制台重置 |
|
||||||
|
| worker 启动后 enrichment_loop 没起 | `docker compose logs worker \| grep "enrichment_loop started"` | 看不到 = enrichment_loop 协程没启动,查 __main__.py 调度 + 重启 |
|
||||||
|
| RSS 抓不到新文章 | `/admin/health` 看 `consecutive_failures`;`docker compose logs worker \| grep "fulltext fetch failed"` | 大概率反爬,暂停源 + 改 UA(已知 case:DW fulltext 需真实浏览器 UA) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 技术栈
|
## 技术栈
|
||||||
|
|||||||
Reference in New Issue
Block a user