Files
scaffold/template/.claude/rules/agent-concurrency.md.jinja
T
baozaotumao 39fe248f9f feat: 重组为标准 skill 包结构 + 修复 verify.sh bash 3.2 兼容
- 新增 .claude/skills/new-project/SKILL.md(标准 skill 格式)
- install-skill.sh 安装时组装 SKILL.md + verify.sh,单一源无重复
- verify.sh 改用索引数组替代 declare -A,兼容 macOS bash 3.2
- log_pass/log_skip 显式 return 0,避免 set -e 下非 verbose 误退出
- README 改为 skill 优先;补全 copier.yml/scripts/template 入库
2026-06-01 21:19:45 -07:00

137 lines
4.9 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 %}一个 Chromium 上下文 + 阶段性 CPU 密集合成。**核心铁律:绝不阻塞事件循环。**
### 工作类型与处理方式
| 工作类型 | 例子 | 处理方式 |
|---------|------|---------|
| 异步 I/O | asyncio 子进程、网络、WebSocket | 直接 `await` |
{% if llm_provider == 'gemini-cli' -%}
| 阻塞 fd 读 | PTY master_fd 读取 | **非阻塞 fd + `loop.add_reader`**,不要 thread-per-session |
{% endif -%}
| CPU 密集 | python-pptx 合成、图像处理 | `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 %}
`synthesize` 节点(python-pptx 合成)是纯 CPU + 持 GIL,会卡住整个事件循环 1–3 秒,影响所有其他会话。必须下放到进程池:
```python
process_pool = ProcessPoolExecutor(max_workers=os.cpu_count())
await loop.run_in_executor(process_pool, build_pptx, screenshots_dir, pptx_path)
```
进程池函数必须是**纯函数**(参数 + 返回值可序列化),禁止访问共享状态。
### {% if llm_provider == 'gemini-cli' %}3{% else %}2{% endif %}. 并发上限与背压
每个会话 = {% if llm_provider == 'gemini-cli' %}1 个 CLI 进程 + {% endif %}1 个 Chromium context,内存/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 %}. Playwright 浏览器复用
启动时拉起**一个** Chromium,每个会话开独立 `browser_context`(廉价),用完关 context,不要每次 `launch()`
```python
# lifespan 启动
app.state.browser = await playwright.chromium.launch()
# 截图节点
context = await app.state.browser.new_context(viewport={"width": 1920, "height": 1080})
try:
page = await context.new_page()
...
finally:
await context.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 context.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`。**