Files
scaffold/template/.claude/rules/agent-concurrency.md.jinja
T
baozaotumao df48c36422 refactor: 规则中性化——剥离 PPT 业务,统一为订单示范域
把规则从原 ppt-gen 项目泛化为通用脚手架,覆盖 19 个文件:
- ddd/architecture/design-discipline/error-codes/communication-* 等:
  领域术语、错误码、消息示例、扩展点、状态流统一为「订单」示范域
- agent-dag/agent.md/agent-concurrency/agent-llm-*:DAG 节点、配置、并发、
  LLM 适配从「对话→预览→生成→截图→合成」改为「intake→process→validate→finalize」
- backend-db:Session 实体/状态机/ORM/仓储/checkpoint 对齐新流水线
- 移除幻灯片专属依赖与产物:agent 去 playwright/python-pptx,
  Dockerfile 去 Chromium 系统库,.gitignore 去 *.pptx
- 保留合法通用项:ANSI(PTY 处理)、Playwright(E2E 测试工具)、Gemini CLI(真实 provider)

验证:gemini-cli/openai-api 两变体生成退出 0,生成项目零 PPT 残留,
backend/agent 骨架均可 import 运行。
2026-06-01 23:15:05 -07:00

136 lines
5.0 KiB
Django/Jinja
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
paths:
- "agent/**"
---
# Agent 并发模型与资源管理
事件驱动的异步 I/O、进程池下放 CPU 密集任务、并发上限背压、昂贵外部资源复用、生命周期取消、工作目录清理与日志规范。
## 并发模型与资源管理
Agent 是最重的并发场景:单进程 FastAPI(单事件循环)要同时服务多个会话,每个会话持有{% if llm_provider == 'gemini-cli' %}一个 LLM CLI 子进程 + {% endif %}若干外部资源 + 阶段性 CPU 密集任务。**核心铁律:绝不阻塞事件循环。**
### 工作类型与处理方式
| 工作类型 | 例子 | 处理方式 |
|---------|------|---------|
| 异步 I/O | asyncio 子进程、网络、WebSocket | 直接 `await` |
{% if llm_provider == 'gemini-cli' -%}
| 阻塞 fd 读 | PTY master_fd 读取 | **非阻塞 fd + `loop.add_reader`**,不要 thread-per-session |
{% endif -%}
| CPU 密集 | 文档/图像处理、加解密、压缩等 | `ProcessPoolExecutor` via `run_in_executor` |
{% if llm_provider != 'gemini-cli' -%}
| LLM 流式 | SDK `async for token in stream` | 直接 `await`,事件驱动 |
{% endif %}
{% if llm_provider == 'gemini-cli' %}
### 1. PTY 读取:事件驱动,零线程
**不要**为每个会话开一个线程跑阻塞 `os.read`(线程池会被会话数打爆)。正确做法是把 master fd 设为非阻塞,注册到事件循环:
```python
os.set_blocking(master_fd, False)
loop.add_reader(master_fd, on_pty_readable) # 可读时回调,零额外线程
def on_pty_readable():
try:
data = os.read(master_fd, 4096)
except BlockingIOError:
return
queue.put_nowait(strip_ansi(data.decode(errors="replace")))
```
清理时务必 `loop.remove_reader(master_fd)`。
### 2. CPU 密集:进程池
{% else %}
### 1. CPU 密集:进程池
{% endif %}
CPU 密集节点(如文档合成、图像处理)是纯 CPU + 持 GIL,会卡住整个事件循环 1–3 秒,影响所有其他会话。必须下放到进程池:
```python
process_pool = ProcessPoolExecutor(max_workers=os.cpu_count())
await loop.run_in_executor(process_pool, build_output, input_dir, out_path)
```
进程池函数必须是**纯函数**(参数 + 返回值可序列化),禁止访问共享状态。
### {% if llm_provider == 'gemini-cli' %}3{% else %}2{% endif %}. 并发上限与背压
每个会话 = {% if llm_provider == 'gemini-cli' %}1 个 CLI 进程 + {% endif %}若干外部资源,内存/CPU 开销大。用全局信号量封顶,超出排队并向前端反馈位置:
```python
session_semaphore = asyncio.Semaphore(settings.max_concurrent_sessions)
async with session_semaphore:
await run_session(...)
# 排队期间向前端推 {"type": "queued", "position": N}
```
### {% if llm_provider == 'gemini-cli' %}4{% else %}3{% endif %}. 昂贵外部资源复用
若某外部资源初始化昂贵(如浏览器、连接池、客户端),启动时拉起**一个**全局实例,每个会话借用廉价的子句柄,用完即还,不要每次重新 `launch()` / 建连:
```python
# lifespan 启动:全局单例(示范用浏览器,按需替换为连接池等)
app.state.pool = await create_expensive_resource()
# 会话节点:借一个廉价子句柄
handle = await app.state.pool.acquire()
try:
...
finally:
await handle.close()
```
### {% if llm_provider == 'gemini-cli' %}5{% else %}4{% endif %}. 资源生命周期与取消
每个会话是一个 asyncio Task。WebSocket 断开 → 取消 Task → `finally` 块彻底清理:
```python
finally:
{% if llm_provider == 'gemini-cli' %}
loop.remove_reader(master_fd)
proc.kill()
await proc.wait() # 必须 wait,回收僵尸进程
os.close(master_fd)
{% else %}
await llm_session.close() # 释放 HTTP 连接
{% endif %}
await handle.close() # 归还借用的外部资源句柄
```
### 共享状态线程安全
所有会话状态活在事件循环里——纯 async 访问无需锁。下放到进程池的函数**严禁触碰共享 dict**,只能通过参数传入、返回值传出。
## 工作目录清理(后台任务)
`lifespan` 启动定时任务(每小时),按 TTL + 磁盘预算清理 `~{{ '/' }}{{ project_slug }}/{uuid}/`
- 删除超 `SESSION_TTL_HOURS`(默认 24h)的会话目录
- 总占用超 `MAX_DISK_GB`(默认 5GB)时按 LRU 删到阈值以下
- 删除前校验路径在 work_dir 内(防穿越)
## 日志规范(core/logging.py
使用 loguru,两个 sink
| Sink | 格式 | 条件 |
|------|------|------|
| stderr | 人类可读彩色格式 | 开发环境(`ENV=dev` |
| `~{{ '/' }}{{ project_slug }}/logs/agent.log` | JSON(每行一条) | 所有环境 |
文件 sink 配轮转与保留:`rotation="100 MB"`、`retention="14 days"`、`compression="zip"`。
每条日志必须携带 `session_id`(通过 loguru contextualize 注入):
```python
with logger.contextualize(session_id=state.session_id):
logger.info("session started")
```
**禁止使用 `print()`,禁止使用 stdlib `logging`。统一用 `from loguru import logger`。**