Skip to content

Commit d406c1d

Browse files
authored
Merge pull request #60 from bob798/claude/performance-investigation-cXVqZ
fix+observability: 修复部署失败 + 日志落盘 + explain 全链路打点
2 parents 76bbd0f + d13ce93 commit d406c1d

11 files changed

Lines changed: 203 additions & 47 deletions

File tree

.dockerignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@ tests/
2020
docs/
2121
*.md
2222
!requirements.txt
23-
fly.toml
2423
docker-compose*.yml
2524
.env*
2625
!.env.example

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ dist/
1717
static/tts_cache/
1818
static/audio_cache/
1919

20+
# 应用日志(容器挂载到 /app/logs · 本地 dev 时写到 ./logs/)
21+
logs/
22+
2023
# OMC runtime state (HUD cache, session state, logs, notepad, project memory)
2124
.omc/state/
2225
.omc/logs/

.omc/plans/lightweight-deployment.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -125,9 +125,9 @@ yt-dlp==<pinned>
125125
| Step | Action | Detail | Acceptance Criteria |
126126
|---|---|---|---|
127127
| 3.1 | Update base to `python:3.12-slim` | Match development environment Python version (3.12). | Dockerfile FROM line updated |
128-
| 3.2 | Review and update existing `.dockerignore` | `.dockerignore` already exists. Verify it excludes: `fly.toml`, `venv/`, `__pycache__/`, `*.db*`, `.git/`, `tests/`, `docs/`, `.omc/`, `*.md`. Add `fly.toml` if missing. | `docker build` context is <5MB |
128+
| 3.2 | Review and update existing `.dockerignore` | `.dockerignore` already exists. Verify it excludes: `venv/`, `__pycache__/`, `*.db*`, `.git/`, `tests/`, `docs/`, `.omc/`, `*.md`. | `docker build` context is <5MB |
129129
| 3.3 | Optimize layer ordering | Copy `requirements.txt` first, `pip install`, then copy app code. (Already done in current Dockerfile — verify preserved.) | Layer cache hits on code-only changes |
130-
| 3.4 | Remove Fly.io-specific ENV | Remove `ENV SPEAKEASY_DB_PATH=/data/speakeasy.db` from Dockerfile (line 19). Move to docker-compose env. | Dockerfile has no hardcoded paths |
130+
| 3.4 | Remove legacy hardcoded ENV | Remove `ENV SPEAKEASY_DB_PATH=/data/speakeasy.db` from Dockerfile (line 19). Move to docker-compose env. | Dockerfile has no hardcoded paths |
131131
| 3.5 | Build and measure | `docker build -t speakeasy:slim .` then `docker images speakeasy:slim` | Image size < 300MB |
132132
| 3.6 | Smoke test the image | `docker run --rm -p 8000:8000 speakeasy:slim` and `curl http://localhost:8000/` | Returns HTML (index.html) |
133133
| 3.7 | Fallback if >300MB | If image exceeds 300MB, investigate `yt-dlp` transitive deps (`certifi`, `brotli`, `mutagen`, `pycryptodomex`, `websockets`). Consider `--no-deps` with explicit sub-deps. | Image under target or deviation documented |
@@ -152,7 +152,7 @@ yt-dlp==<pinned>
152152
| 4.3 | Create `.env.production.example` | Template with all required vars: `DOMAIN`, `MODEL_PROVIDER=volcengine`, `VOLCENGINE_API_KEY`, `GROQ_API_KEY`, `SPEAKEASY_DB_PATH=/data/speakeasy.db`. | File exists, no real secrets committed |
153153
| 4.4 | Create `.github/workflows/deploy.yml` | Trigger on push to `main`. Steps: checkout → login GHCR → build+push image → SSH to VPS → `docker compose pull && docker compose up -d`. | YAML validates |
154154
| 4.5 | Update `.gitignore` | Add `.env.production`, verify `speakeasy.db*`, `static/tts_cache/*`, `static/audio_cache/*` are excluded | Sensitive files excluded |
155-
| 4.6 | Add `fly.toml` legacy comment | Add comment at top: `# LEGACY: Fly.io config, replaced by docker-compose. Remove in future PR.` | Comment added |
155+
| 4.6 | ~~Add `fly.toml` legacy comment~~ | DONE — `fly.toml` 已彻底移除(仓库不再保留 Fly.io 配置)。 | n/a |
156156
| 4.7 | Write deployment README section | Setup instructions: clone, copy `.env.production.example`, `docker compose up -d`. Include rollback: `docker compose pull <previous-tag> && docker compose up -d`. | Instructions copy-pasteable on Ubuntu 22.04+ with Docker |
157157

158158
**docker-compose.yml structure:**
@@ -258,7 +258,7 @@ networks:
258258
- Build on VPS: rejected — requires build tooling on VPS; GHCR approach means VPS only needs Docker
259259
- **Why chosen:** Caddy eliminates TLS ops entirely. GitHub Actions + GHCR is free and avoids build tooling on VPS. Slim base avoids musl wheel compilation failures.
260260
- **Consequences:** Caddy adds ~40MB to the stack (acceptable). GitHub Actions creates vendor dependency (mitigated: workflow is portable to any CI with SSH). GHCR requires VPS to have network access to ghcr.io.
261-
- **Follow-ups:** Evaluate `fly.toml` full removal in a future PR. Consider SQLite backup strategy for production.
261+
- **Follow-ups:** ~~Evaluate `fly.toml` full removal in a future PR.~~ DONE. Consider SQLite backup strategy for production.
262262

263263
---
264264

.omc/specs/deep-interview-lightweight-deployment.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@
6969
- **STT**: Groq Whisper-large-v3
7070
- **TTS**: Edge-TTS (primary) + OpenAI TTS (fallback)
7171
- **VAD**: Silero VAD (PyTorch) — **TO BE REMOVED**
72-
- **Deployment**: Fly.io (fly.toml) — **TO BE REPLACED**
72+
- **Deployment**: VPS + docker-compose + Caddy(原 Fly.io 已迁出,`fly.toml` 已移除)
7373

7474
### 需要修改的文件
7575
- `app/services/vad_service.py` — 删除

Dockerfile

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,8 @@ ENV APP_VERSION="${APP_VERSION}"
3535
# 用 Stage 1 构建好的 dist 覆盖 frontend/dist
3636
COPY --from=frontend-build /build/dist ./frontend/dist
3737

38-
# 数据目录(挂载 volume 也行 · 这里兜底)
39-
RUN mkdir -p static/tts_cache static/audio_cache
38+
# 数据 / 日志目录(挂载 volume 也行 · 这里兜底,确保未挂卷的镜像也能直接跑
39+
RUN mkdir -p static/tts_cache static/audio_cache /app/logs /app/data
4040

4141
EXPOSE 8000
4242

app/logger.py

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,30 @@
11
import logging
22
import os
3+
import pathlib
34
from contextvars import ContextVar
5+
from logging.handlers import RotatingFileHandler
6+
from typing import Optional
47

58
# 通过 contextvars 在异步环境中安全传递 request_id
69
request_id_var: ContextVar[str] = ContextVar("request_id", default="-")
710

811
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "INFO").upper()
912

13+
# 文件日志配置(VPS 部署后线上需要持久日志)
14+
# - LOG_TO_FILE : 是否写文件,"0"/"false" 关闭(默认开)
15+
# - LOG_DIR : 日志目录,默认 "logs"(docker-compose 里挂载到 /app/logs)
16+
# - LOG_FILE : 文件名,默认 "app.log"
17+
# - LOG_FILE_MAX_BYTES : 单文件上限,默认 10 MB
18+
# - LOG_FILE_BACKUP_COUNT: 保留轮转份数,默认 5
19+
LOG_TO_FILE: bool = os.getenv("LOG_TO_FILE", "1").lower() not in {"0", "false", "no"}
20+
LOG_DIR: str = os.getenv("LOG_DIR", "logs")
21+
LOG_FILE: str = os.getenv("LOG_FILE", "app.log")
22+
LOG_FILE_MAX_BYTES: int = int(os.getenv("LOG_FILE_MAX_BYTES", str(10 * 1024 * 1024)))
23+
LOG_FILE_BACKUP_COUNT: int = int(os.getenv("LOG_FILE_BACKUP_COUNT", "5"))
24+
25+
_FMT = "%(asctime)s | %(levelname)-5s | %(name)s | [%(request_id)s] %(message)s"
26+
_DATEFMT = "%Y-%m-%d %H:%M:%S"
27+
1028

1129
class _RequestIdFormatter(logging.Formatter):
1230
"""在每条日志里自动注入当前请求的 request_id"""
@@ -16,16 +34,49 @@ def format(self, record: logging.LogRecord) -> str:
1634
return super().format(record)
1735

1836

37+
_formatter = _RequestIdFormatter(_FMT, datefmt=_DATEFMT)
38+
_file_handler: Optional[logging.Handler] = None
39+
_file_handler_attempted = False
40+
41+
42+
def _build_file_handler() -> Optional[logging.Handler]:
43+
"""惰性初始化共享的文件 handler;目录创建失败时降级为 None 不阻断启动"""
44+
global _file_handler, _file_handler_attempted
45+
if _file_handler_attempted:
46+
return _file_handler
47+
_file_handler_attempted = True
48+
if not LOG_TO_FILE:
49+
return None
50+
try:
51+
log_dir = pathlib.Path(LOG_DIR)
52+
log_dir.mkdir(parents=True, exist_ok=True)
53+
handler = RotatingFileHandler(
54+
log_dir / LOG_FILE,
55+
maxBytes=LOG_FILE_MAX_BYTES,
56+
backupCount=LOG_FILE_BACKUP_COUNT,
57+
encoding="utf-8",
58+
)
59+
handler.setFormatter(_formatter)
60+
_file_handler = handler
61+
except OSError as e:
62+
# 容器/VPS 上偶发目录不可写时不要让应用起不来,记一行到 stderr 后降级到仅 stdout
63+
logging.getLogger(__name__).warning(
64+
"无法创建日志文件 %s/%s(%s);仅输出到 stdout", LOG_DIR, LOG_FILE, e
65+
)
66+
_file_handler = None
67+
return _file_handler
68+
69+
1970
def get_logger(name: str) -> logging.Logger:
2071
"""返回已配置好格式和级别的 logger,避免重复添加 handler"""
2172
logger = logging.getLogger(name)
2273
if not logger.handlers:
23-
handler = logging.StreamHandler()
24-
fmt = "%(asctime)s | %(levelname)-5s | %(name)s | [%(request_id)s] %(message)s"
25-
handler.setFormatter(
26-
_RequestIdFormatter(fmt, datefmt="%Y-%m-%d %H:%M:%S")
27-
)
28-
logger.addHandler(handler)
74+
stream_handler = logging.StreamHandler()
75+
stream_handler.setFormatter(_formatter)
76+
logger.addHandler(stream_handler)
77+
file_handler = _build_file_handler()
78+
if file_handler is not None:
79+
logger.addHandler(file_handler)
2980
logger.setLevel(LOG_LEVEL)
3081
logger.propagate = False
3182
return logger

app/services/explain_service.py

Lines changed: 80 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,14 +77,16 @@ def _normalize_level(level: Optional[str]) -> str:
7777

7878

7979
def _get_user_level(user_id: str) -> str:
80+
t0 = time.time()
8081
with OrmSession(engine) as s:
8182
row = s.query(UserProfile).filter_by(user_id=user_id).first()
82-
if row and row.cefr_level:
83-
return _normalize_level(row.cefr_level)
84-
return ""
83+
level = _normalize_level(row.cefr_level) if row and row.cefr_level else ""
84+
logger.debug("user_level 查询 user=%s level=%r 耗时=%.3fs", user_id[:8], level, time.time() - t0)
85+
return level
8586

8687

8788
def _cache_get(text: str, kind: str, cefr_level: str) -> Optional[Dict]:
89+
t0 = time.time()
8890
h = _hash_text(text)
8991
with OrmSession(engine) as s:
9092
row = (
@@ -96,13 +98,24 @@ def _cache_get(text: str, kind: str, cefr_level: str) -> Optional[Dict]:
9698
row.hit_count = (row.hit_count or 0) + 1
9799
s.commit()
98100
try:
99-
return json.loads(row.explanation)
101+
data = json.loads(row.explanation)
102+
logger.info(
103+
"cache_get HIT kind=%s level=%s hit_count=%d 耗时=%.3fs",
104+
kind, cefr_level, row.hit_count, time.time() - t0,
105+
)
106+
return data
100107
except json.JSONDecodeError:
108+
logger.warning(
109+
"cache_get 命中但 JSON 解析失败 kind=%s level=%s 耗时=%.3fs",
110+
kind, cefr_level, time.time() - t0,
111+
)
101112
return None
113+
logger.info("cache_get MISS kind=%s level=%s 耗时=%.3fs", kind, cefr_level, time.time() - t0)
102114
return None
103115

104116

105117
def _cache_set(text: str, kind: str, cefr_level: str, explanation: Dict, overwrite: bool = False) -> None:
118+
t0 = time.time()
106119
h = _hash_text(text)
107120
with OrmSession(engine) as s:
108121
existing = (
@@ -116,6 +129,9 @@ def _cache_set(text: str, kind: str, cefr_level: str, explanation: Dict, overwri
116129
existing.source_text = text
117130
existing.hit_count = 1 # 重生成清零计数
118131
s.commit()
132+
logger.debug("cache_set OVERWRITE kind=%s level=%s 耗时=%.3fs", kind, cefr_level, time.time() - t0)
133+
else:
134+
logger.debug("cache_set SKIP(已存在)kind=%s level=%s 耗时=%.3fs", kind, cefr_level, time.time() - t0)
119135
return
120136
s.add(
121137
ExplanationCache(
@@ -128,8 +144,10 @@ def _cache_set(text: str, kind: str, cefr_level: str, explanation: Dict, overwri
128144
)
129145
try:
130146
s.commit()
147+
logger.debug("cache_set INSERT kind=%s level=%s 耗时=%.3fs", kind, cefr_level, time.time() - t0)
131148
except IntegrityError:
132149
s.rollback()
150+
logger.warning("cache_set IntegrityError kind=%s level=%s 耗时=%.3fs", kind, cefr_level, time.time() - t0)
133151

134152

135153
def _strip_code_fences(raw: str) -> str:
@@ -190,6 +208,7 @@ async def explain_text(
190208
:param context: word 模式下提供所在句子,sentence 模式下忽略
191209
:param force: True 时跳过缓存,重新生成并覆盖;带 3s debounce
192210
"""
211+
t_start = time.time()
193212
if not text or not text.strip():
194213
raise ValueError("解读内容不能为空")
195214
text = text.strip()
@@ -198,6 +217,12 @@ async def explain_text(
198217
if kind not in VALID_KINDS:
199218
raise ValueError(f"无效的解读类型: {kind},可选值: sentence / word")
200219

220+
preview = text if len(text) <= 30 else text[:30] + "…"
221+
logger.info(
222+
"explain_text 入口 kind=%s text_len=%d preview=%r user=%s force=%s",
223+
kind, len(text), preview, user_id[:8] if user_id else "-", force,
224+
)
225+
201226
cefr_level = _get_user_level(user_id)
202227
cache_level = cefr_level or _DEFAULT_LEVEL
203228

@@ -207,30 +232,58 @@ async def explain_text(
207232
else:
208233
cached = _cache_get(text, kind, cache_level)
209234
if cached is not None:
235+
logger.info(
236+
"explain_text 完成 kind=%s 来源=cache 总耗时=%.3fs",
237+
kind, time.time() - t_start,
238+
)
210239
return {"explanation": cached, "cefr_level": cache_level, "cached": True}
211240

212241
prompt_level = cefr_level or _DEFAULT_LEVEL
242+
t_prompt = time.time()
213243
if kind == "sentence":
244+
liaison = liaison_prompt_block()
214245
system_prompt = EXPLAIN_SENTENCE_PROMPT.format(
215246
cefr_level=prompt_level,
216-
liaison_kb=liaison_prompt_block(),
247+
liaison_kb=liaison,
248+
)
249+
logger.debug(
250+
"prompt 拼装 kind=sentence system_len=%d liaison_len=%d 耗时=%.3fs",
251+
len(system_prompt), len(liaison), time.time() - t_prompt,
217252
)
218253
else:
219254
system_prompt = EXPLAIN_WORD_PROMPT.format(
220255
cefr_level=prompt_level,
221256
text=text.replace('"', '\\"'),
222257
context=(context or "").replace('"', '\\"'),
223258
)
259+
logger.debug(
260+
"prompt 拼装 kind=word system_len=%d context_len=%d 耗时=%.3fs",
261+
len(system_prompt), len(context or ""), time.time() - t_prompt,
262+
)
224263

225264
messages = [
226265
{"role": "system", "content": system_prompt},
227266
{"role": "user", "content": text},
228267
]
229268
client = get_client()
269+
t_llm = time.time()
230270
raw = await client.complete(messages, max_tokens=2000, scene="explain")
271+
llm_elapsed = time.time() - t_llm
272+
logger.info(
273+
"explain_text LLM 完成 kind=%s output_len=%d 耗时=%.2fs",
274+
kind, len(raw or ""), llm_elapsed,
275+
)
276+
277+
t_parse = time.time()
231278
explanation = _parse_explanation(raw)
279+
logger.debug("explain_text JSON 解析 耗时=%.3fs fields=%d", time.time() - t_parse, len(explanation))
232280

233281
_cache_set(text, kind, cache_level, explanation, overwrite=force)
282+
total = time.time() - t_start
283+
logger.info(
284+
"explain_text 完成 kind=%s 来源=llm 总耗时=%.2fs (LLM 占比=%.0f%%)",
285+
kind, total, (llm_elapsed / total * 100) if total > 0 else 0,
286+
)
234287
return {"explanation": explanation, "cefr_level": cache_level, "cached": False, "regenerated": force}
235288

236289

@@ -266,12 +319,19 @@ async def stream_sentence_explanation(
266319
- 结束后额外 yield 一个 {"_done": True} 事件
267320
:param force: True 时跳过缓存重新生成(带 3s debounce)
268321
"""
322+
t_start = time.time()
269323
if not text or not text.strip():
270324
raise ValueError("解读内容不能为空")
271325
text = text.strip()
272326
if len(text) > MAX_TEXT_LEN:
273327
raise ValueError(f"解读内容不能超过 {MAX_TEXT_LEN} 字符")
274328

329+
preview = text if len(text) <= 30 else text[:30] + "…"
330+
logger.info(
331+
"stream_sentence 入口 text_len=%d preview=%r user=%s force=%s",
332+
len(text), preview, user_id[:8] if user_id else "-", force,
333+
)
334+
275335
cefr_level = _get_user_level(user_id)
276336
cache_level = cefr_level or _DEFAULT_LEVEL
277337

@@ -282,23 +342,31 @@ async def stream_sentence_explanation(
282342
else:
283343
cached = _cache_get(text, "sentence", cache_level)
284344
if cached is not None:
345+
logger.info("stream_sentence 来源=cache 总耗时=%.3fs", time.time() - t_start)
285346
yield json.dumps({"_cached": True, "cefr_level": cache_level, "explanation": cached}, ensure_ascii=False)
286347
return
287348

288349
prompt_level = cefr_level or _DEFAULT_LEVEL
350+
t_prompt = time.time()
351+
liaison = liaison_prompt_block()
289352
system_prompt = (
290353
EXPLAIN_SENTENCE_PROMPT.format(
291354
cefr_level=prompt_level,
292-
liaison_kb=liaison_prompt_block(),
355+
liaison_kb=liaison,
293356
)
294357
+ _STREAM_FORMAT_HINT
295358
)
359+
logger.debug(
360+
"stream prompt 拼装 system_len=%d liaison_len=%d 耗时=%.3fs",
361+
len(system_prompt), len(liaison), time.time() - t_prompt,
362+
)
296363
messages = [
297364
{"role": "system", "content": system_prompt},
298365
{"role": "user", "content": text},
299366
]
300367

301368
client = get_client()
369+
t_llm = time.time()
302370
buffer = ""
303371
collected: Dict[str, object] = {}
304372
async for chunk in client.chat_stream_messages(messages, scene="explain"):
@@ -332,6 +400,12 @@ async def stream_sentence_explanation(
332400
except json.JSONDecodeError:
333401
pass
334402

403+
llm_elapsed = time.time() - t_llm
335404
if collected:
336405
_cache_set(text, "sentence", cache_level, collected, overwrite=force)
406+
total = time.time() - t_start
407+
logger.info(
408+
"stream_sentence 完成 来源=llm fields=%d LLM=%.2fs 总耗时=%.2fs",
409+
len(collected), llm_elapsed, total,
410+
)
337411
yield json.dumps({"_done": True, "cefr_level": cache_level, "regenerated": force}, ensure_ascii=False)

docker-compose.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@ services:
1111
# SQLite 落到挂载卷 · 容器重启/镜像更新数据不丢
1212
DATABASE_URL: sqlite+aiosqlite:////app/data/speakeasy.db
1313
SPEAKEASY_DB_PATH: /app/data/speakeasy.db
14+
# 日志落到 /app/logs(挂 named volume,宿主机可 `docker compose exec` 查看或挂主机目录持久化)
15+
LOG_DIR: /app/logs
1416
volumes:
1517
- /etc/localtime:/etc/localtime:ro
16-
- logs:/javarun/logs
18+
- logs:/app/logs
1719
- data:/app/data
1820
ports:
1921
- "8600:8000"

docs/decisions/v02a-tech-decisions.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ Speakeasy 的部署路径更可能是:
5858
| 阶段 | 部署方式 | 是否需要 Nginx |
5959
|------|----------|----------------|
6060
| 本地开发 | `uvicorn main:app --reload` ||
61-
| 早期上线 | Railway / Render / Fly.io | ❌(平台内置 HTTPS + 反代) |
61+
| 早期上线 | VPS + docker-compose + Caddy(当前方案) | ❌(Caddy 自动 HTTPS + 反代) |
6262
| 规模化 | 容器化 → 云平台 LB | ❌(云 LB 原生支持 SSE) |
6363
| 特殊场景 | 自管服务器 | ✅ 届时配置 |
6464

0 commit comments

Comments
 (0)