paper-to-ppt¶
第5章 · Coding Agent 与代码生成 · 配套项目
chapter5/paper-to-ppt
项目说明¶
实验 5-4:基于论文的 PPT 自动生成(提议者-审核者机制)¶
配套《深入理解 AI Agent》第 5 章。把"做 PPT"重构为代码生成问题:用 Slidev(Markdown + HTML 定义幻灯片)框架,让 Agent 从一篇论文 自动生成演示文稿,并用提议者-审核者(Proposer-Reviewer)机制做视觉质量控制。
一句话结论¶
Proposer 只写 Slidev 代码、Reviewer 真正把每页渲染成 PNG 再用 Vision LLM 看图 挑毛病(文字溢出 / 内容拥挤 / 图片尺寸),Proposer 据结构化反馈迭代修订。相比"单 Agent 自审"(把历次渲染图片都堆在同一上下文里),双 Agent 分工的上下文峰值显著更小—— 因为 Proposer 全程不看图片、Reviewer 每轮只看最新一版截图。
为什么需要"渲染出来再看"¶
Agent 写完 Slidev 代码时并不知道实际渲染效果:内容会不会太挤、文字会不会溢出、 图片尺寸是否合适——这些只有真正渲染成像素才看得出来。所以 Reviewer 接触到的是 Proposer 看不到的新信息(渲染结果),这正是本机制的价值所在。
提议者-审核者分工¶
| 角色 | 职责 | 上下文里有什么 |
|---|---|---|
Proposer(gpt-5.6-luna,纯文本) |
读论文 → 规划页面 → 生成/修订 slides.md |
论文正文 + 累积的结构化文字反馈(永不含图片) |
Reviewer(gpt-5.6-luna,Vision) |
看最新一版每页 PNG,输出结构化建议 JSON | 每轮全新调用,只含最新一版截图 |
Reviewer 的建议是结构化、可执行的,而非模糊的"不好看",包含字段:
page(页码)、issue_type(text_overflow/overcrowded/image_size/readability/layout)、
severity(high/medium/low)、suggestion(具体修改建议)、以及整份的 overall_score 与 pass。
Proposer 收到反馈 → 理解意图 → 修订代码 → 再次提交 Reviewer,循环直到 pass 或达最大轮数。
对照实验:单 Agent 自审 vs 双 Agent 分工¶
demo.py 同时跑两种方案,并用同一位独立 Vision 评委给两者的最终 PPT 打分(保证质量可比):
- 方案 A 双 Agent:如上。Proposer 上下文只增文本;Reviewer 每轮重置、只看最新截图。
- 方案 B 单 Agent 自审:一个 Agent 在同一段对话里生成 → 看自己的渲染截图自审 → 修订。 历次渲染的图片会一直留在上下文里,随迭代快速膨胀(书中所述"上下文迅速超限")。
脚本打印每次调用的 prompt token 序列、总量、以及上下文峰值(单次 prompt token, 决定是否撑爆上下文窗口)。页数越多、迭代越多,方案 B 的峰值相对方案 A 越夸张。
运行¶
# 1) Python 依赖
pip install -r requirements.txt
# 2) Slidev + 渲染依赖(Node)。首次约 1-2 分钟:
npm install
# - @slidev/cli:Slidev 命令行
# - playwright-chromium:slidev export --format png 的底层浏览器
# - typescript:Slidev 的 twoslash 代码高亮所需(否则 export 会 ERR_MODULE_NOT_FOUND)
# 若 npm install 没有自动装好 chromium 浏览器二进制,运行:
# npx playwright install chromium
# 3) 配置 Key
cp env.example .env # 填入 OPENAI_API_KEY(未配置时设 OPENROUTER_API_KEY 自动改走 OpenRouter)
# 4) 跑完整流程(生成 → 渲染 → Vision 审查 → 迭代 → 对比)
python demo.py
常用参数(python demo.py --help)¶
一次完整运行会做数十次 gpt-5.6-luna Vision 调用,较慢较贵。下列参数提供更快的路径,并允许更换论文、输出目录与模型:
| 参数 | 作用 |
|---|---|
--paper PATH |
输入论文的 Markdown 路径(默认 paper/sample_paper.md)。换成你自己的论文即可。 |
--out-dir DIR |
产物输出目录(默认 output/):各轮 slides.md/review.json/comparison_summary.json。渲染 PNG 始终在 slidev_workspace/exports/。 |
--text-model NAME |
Proposer / 单 Agent 文本模型,覆盖 TEXT_MODEL 环境变量(默认 gpt-5.6-luna)。 |
--vision-model NAME |
Reviewer / 独立评委看图模型(须支持图像),覆盖 VISION_MODEL 环境变量(默认 gpt-5.6-luna)。 |
--mode {both,dual,single} |
只跑一种方案(dual=提议者-审核者,single=单 Agent 自审),省一半时间/费用;both(默认)才做跨方案对比。 |
--max-rounds N |
每种方案的最大迭代轮数(默认 3)。--max-rounds 1 只出首版、不修订,是最快的真实 LLM 冒烟。 |
--dry-run |
离线走通提议者-审核者循环:真实渲染两版脚本化 slides.md(拥挤初稿→拆页修订稿),用确定性启发式规则(按每页文字量判定,非 Vision LLM)扮演 Reviewer,完整展示“生成→渲染→审查→修订”闭环。不调用任何 LLM、无需 API Key。 |
--smoke |
只验证 Slidev 渲染链路(渲染一个两页 deck),不调用任何 LLM、无需 API Key。最快的“没搞坏渲染”自检。 |
python demo.py --smoke # 不花钱,验证 Node/Slidev/chromium 可用
python demo.py --dry-run # 不花钱,离线看清提议者-审核者闭环(真实渲染)
python demo.py --mode dual --max-rounds 1 # 一次真实 LLM 冒烟(需 API Key)
python demo.py --paper my_paper.md --out-dir run_my # 换论文、换输出目录
--dry-run里的两版slides.md是脚本化的(不是 LLM 生成),Reviewer 也只是按字符数判定拥挤的启发式规则、并非 Vision LLM——它只用来在没有 API Key 时把闭环结构跑通、产出真实渲染的 PNG。要看 gpt-5.6-luna 真的看像素审查,请用python demo.py(需OPENAI_API_KEY)。一次离线 dry-run 的真实结果:初稿 4 页(第 2/3/4 页被判 high 级 overcrowded、score=55、pass=False)→ 拆页修订稿 18 页(score=100、pass=True),渲染 PNG 见slidev_workspace/exports/dryrun_round*/。
文件说明¶
| 文件 | 作用 |
|---|---|
demo.py |
主流程:跑两种方案、独立评委打分、打印 token 对比 |
agents.py |
Proposer / Reviewer / SelfReviewAgent 三个 Agent + TokenMeter 计量 |
renderer.py |
调 slidev export --format png 把 slides.md 渲染成逐页 PNG |
make_figures.py |
用 matplotlib 从论文数字复现 2 张图表,放进 Slidev public/ |
paper/sample_paper.md |
精简论文(FlashAttention,含标题/章节/表格/结果) |
package.json |
Slidev 与渲染依赖 |
output/ |
运行产物:各轮 slides.md、review.json、comparison_summary.json |
slidev_workspace/exports/ |
各轮渲染出的 PNG(dual_round1/、single_round1/ …) |
预期输出示例¶
一次完整运行后,output/ 与 slidev_workspace/exports/ 下的真实产物(节选):
output/
├── dual_round1_slides.md # 双 Agent 第 1 版 slidev 源码(首版故意很挤)
├── dual_round1_review.json # Reviewer 对第 1 版的结构化建议 JSON
├── dual_round2_slides.md # 据反馈修订后的第 2 版
├── dual_round2_review.json
├── dual_round3_slides.md
├── single_round1_slides.md # 单 Agent 自审各版
├── single_round2_slides.md
├── single_round3_slides.md
└── comparison_summary.json # 两方案质量分 + token 消耗汇总
slidev_workspace/exports/
├── dual_round1/1.png … 5.png # 首版渲染:段落太长、图表底部超出页面
├── dual_round2/1.png … 8.png # 修订版:拆页后每页 8 张更干净
└── single_round1/1.png … # 单 Agent 各版渲染
说明:Slidev 的 PNG 导出是逐页一张 PNG(
1.png、2.png…),本实验不产出单一 PDF; 如需 PDF,可把renderer.py里的--format png改为--format pdf。comparison_summary.json里记录两方案的iteration_scores、final_quality与peak_context_prompt_tokens(上下文峰值),即书中的核心对比数据。
如何适配 / 扩展¶
- 换模型 / 换供应商:通过环境变量(见
env.example)或命令行参数(优先级更高),代码无需改动。 OPENAI_API_KEY:密钥(必填其一;未配置时用OPENROUTER_API_KEY兜底,自动改走 OpenRouter)。OPENAI_BASE_URL:指向任何兼容 OpenAI 协议的端点(自建网关 / 其它供应商)。TEXT_MODEL/--text-model:Proposer / 单 Agent 文本部分用的模型(默认gpt-5.6-luna)。VISION_MODEL/--vision-model:Reviewer / 独立评委看图用的模型,必须支持图像输入(默认gpt-5.6-luna)。- 换输入论文 / 输出目录:命令行
--paper PATH指定论文、--out-dir DIR指定产物目录(无需改代码); 也可直接替换paper/sample_paper.md(保留 Markdown 章节结构即可)。若新论文有自己的 数据图表,改make_figures.py里的画图函数并更新generate_all()返回的{文件名: 描述}, Proposer 会据描述引用这些图。 - Slidev 渲染依赖(重要):渲染链路依赖 Node + 本目录内
node_modules/,其中包含@slidev/cli、playwright-chromium(slidev export --format png的底层浏览器)、typescript(twoslash 代码高亮所需)。若node_modules/缺失或损坏,在本目录执行npm install重装; 若浏览器二进制没装好,补跑npx playwright install chromium。装好后先python demo.py --smoke验证渲染链路,再跑完整流程。
关于"第一版故意写得很挤"¶
为了稳定复现"渲染 → 发现问题 → 修订"的闭环,agents.py 里让 Proposer/单 Agent 的
首版先把整篇论文塞进约 4 页、成段贴原文(一种常见的"先把内容倒进去"的初稿写法)。
这会产生真实的文字溢出与图表被裁切(见 slidev_workspace/exports/dual_round1/2.png:
段落太长、图表底部超出页面)。Reviewer 的问题都是视觉模型(默认 gpt-5.6-luna)看真实像素得出的,修订也是
真实的——不是预设脚本。若把首版指令改成"直接生成 8-12 页精简版",视觉模型往往一版就过关,
反而看不到迭代过程。一次真实运行的结果(会有随机波动):
双 Agent:round1 score=85 pass=False(4 个 medium:p2/p3/p4 overcrowded、p2 image_size)
→ Proposer 拆页精简 → round2 score=95 pass=True(+10 改善)
上下文峰值:双 Agent = 9308 tok,单 Agent 自审 = 14179 tok(单 Agent 图片累积:1640→8069→14179)
局限¶
- 审美主观:Reviewer 的偏好未必等于目标用户的偏好,反馈循环可能收敛到 Reviewer 认可但用户嫌挤的局部最优(见书末思考题:如何让用户偏好也进入循环)。
- 图表来源:本实验不解析真实 PDF,图表由
make_figures.py从论文数字程序化复现, 作为"论文原始图表"的替身;接入真实 PDF 需另加图片抽取。 - 成本/时长:每轮 Reviewer 要把约 10 张截图发给视觉模型(默认 gpt-5.6-luna),单次运行需数十次 API 调用;已把截图统一缩放到 1280px 宽以控制 token。
- 确定性:LLM 与 Vision 判定有随机性,具体分数/建议每次略有不同;
temperature已调低,但迭代是否恰好"1 轮达标"取决于首版质量。 - 渲染依赖:
slidev export依赖 playwright-chromium;无网络/无法装 chromium 的 环境需先解决浏览器二进制问题(见"运行"第 2 步)。
源代码¶
agents.py¶
"""
提议者(Proposer)与审核者(Reviewer)两个 Agent,以及一个带 token 计量的 LLM 客户端。
设计要点(对应书中“提议者-审核者”机制):
- Proposer 只处理**文本**:论文正文 + 累积的结构化文字反馈;从不接收渲染图片。
- Reviewer 每一轮**只看最新一版的渲染截图**,且每轮都是一次全新的、无历史的调用。
- 单 Agent 自审对照组则相反:同一段对话里不断累积历次渲染的图片,上下文迅速膨胀。
所有对 OpenAI 的调用都经过 TokenMeter 统计 prompt / completion token,
用于最后的“单 Agent vs 双 Agent 上下文消耗”对比。
"""
import base64
import io
import json
import os
import re
from openai import OpenAI
from PIL import Image
# 文本生成用的模型(Proposer / 单 Agent 的文本部分)
TEXT_MODEL = os.environ.get("TEXT_MODEL", "gpt-5.6-luna")
# 视觉审查用的模型(Reviewer / 单 Agent 的看图部分),必须支持图像
VISION_MODEL = os.environ.get("VISION_MODEL", "gpt-5.6-luna")
OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
def map_model_to_openrouter(model: str) -> str:
"""把直连模型名映射为 OpenRouter 上的 id(非可映射 id 统一兜底到当前廉价旗舰)。"""
if not model or "/" in model:
return model or "openai/gpt-5.6-luna"
m = model.lower()
if m.startswith(("gpt-", "o1", "o3", "o4")):
return "openai/" + model
if m.startswith("claude"):
if "haiku" in m:
return "anthropic/claude-haiku-4.5"
if "sonnet" in m:
return "anthropic/claude-sonnet-4.6"
return "anthropic/claude-opus-4.8"
if m.startswith("gemini"):
return "google/" + model
return "openai/gpt-5.6-luna"
# 发送给 Vision 前把截图缩放到该宽度,兼顾“看得清文字溢出”与“控制 token 成本”
VISION_IMAGE_WIDTH = 1280
class TokenMeter:
"""累计一个“角色/模式”消耗的 token,并记录每次调用的 prompt token(用于看上下文峰值)。"""
def __init__(self, name: str):
self.name = name
self.prompt_tokens = 0
self.completion_tokens = 0
self.calls = 0
self.peak_prompt_tokens = 0 # 单次调用最大的 prompt token —— 决定是否“撑爆上下文”
self.per_call_prompt = []
def add(self, usage):
self.calls += 1
pt = usage.prompt_tokens
self.prompt_tokens += pt
self.completion_tokens += usage.completion_tokens
self.peak_prompt_tokens = max(self.peak_prompt_tokens, pt)
self.per_call_prompt.append(pt)
@property
def total_tokens(self):
return self.prompt_tokens + self.completion_tokens
def _client() -> OpenAI:
# 通用 OpenRouter 兜底:无直连 key,或默认 gpt-5.x(直连需组织实名认证)时改走 OpenRouter。
global TEXT_MODEL, VISION_MODEL
api_key = os.environ.get("OPENAI_API_KEY")
base_url = os.environ.get("OPENAI_BASE_URL")
orkey = os.environ.get("OPENROUTER_API_KEY")
prefer_or = bool(orkey) and (
(TEXT_MODEL or "").lower().startswith("gpt-5") or (VISION_MODEL or "").lower().startswith("gpt-5")
)
if prefer_or or (not api_key and orkey):
api_key, base_url = orkey, OPENROUTER_BASE_URL
# 走 OpenRouter 时把模型名映射为其 id(幂等:已带前缀的 id 原样返回)。
TEXT_MODEL = map_model_to_openrouter(TEXT_MODEL)
VISION_MODEL = map_model_to_openrouter(VISION_MODEL)
if not api_key:
raise SystemExit(
"❌ 未检测到 OPENAI_API_KEY(或 OPENROUTER_API_KEY 兜底)。请先 `cp env.example .env` 并填入有效的 "
"OpenAI API Key(或 `export OPENAI_API_KEY=sk-...` / `export OPENROUTER_API_KEY=...`)后再运行。"
)
# timeout + max_retries:单次网络抖动/SSL 中断会自动重试,而不是让整条流水线崩溃。
return OpenAI(
api_key=api_key,
base_url=base_url,
timeout=60.0,
max_retries=4,
)
def encode_image(path: str) -> str:
"""读取 PNG,缩放到统一宽度,编码为 data URL(base64)。"""
img = Image.open(path).convert("RGB")
if img.width > VISION_IMAGE_WIDTH:
h = int(img.height * VISION_IMAGE_WIDTH / img.width)
img = img.resize((VISION_IMAGE_WIDTH, h), Image.LANCZOS)
buf = io.BytesIO()
img.save(buf, format="PNG")
b64 = base64.b64encode(buf.getvalue()).decode()
return f"data:image/png;base64,{b64}"
def _extract_json(text: str):
"""从模型回复里稳健地抽取 JSON(容忍 ```json 代码块或前后多余文字)。"""
text = text.strip()
m = re.search(r"```(?:json)?\s*(.*?)```", text, re.DOTALL)
if m:
text = m.group(1).strip()
# 找到第一个 { 到最后一个 }
start = text.find("{")
end = text.rfind("}")
if start != -1 and end != -1:
text = text[start:end + 1]
return json.loads(text)
def _extract_slides_md(text: str) -> str:
"""从模型回复里抽取 slides.md 内容(容忍 ```markdown 包裹)。"""
m = re.search(r"```(?:markdown|md)?\s*(.*?)```", text, re.DOTALL)
if m:
return m.group(1).strip()
return text.strip()
# --------------------------------------------------------------------------- #
# Reviewer 的审查评分标准(Proposer / 单 Agent / 独立评委共用同一套 rubric)
# --------------------------------------------------------------------------- #
REVIEW_RUBRIC = """你是一名严格的演示文稿质量审核员(Reviewer)。你会看到一份由 Slidev 渲染出的 PPT,
每张图对应一页幻灯片(按顺序编号,从第 1 页开始)。请逐页检查以下问题:
- text_overflow(文字溢出/被裁切超出页面边界)
- overcrowded(内容过多/过于拥挤/留白不足)
- image_size(图片过大顶出布局,或过小看不清)
- readability(字号过小、对比度差、代码块难读)
- layout(对齐混乱、标题与正文比例失衡、空页)
请以**目标用户是听众**的严格标准审查——一页幻灯片若要点超过约 5 条、或正文文字块偏长、
或图片挤压了文字空间,都应视为 overcrowded/image_size 问题。报告真实存在的问题,
但不要放过"塞得太满"。对每个问题给出:page(页码,整数)、
issue_type(上面之一)、severity(high/medium/low)、suggestion(具体、可执行的修改建议,中文)。
同时给出:
- overall_score:0-100 的整体质量分(越高越好)
- pass:布尔值,仅当整份 PPT **既无 high 也无 medium 级问题**、排版干净可读时才为 true
严格输出如下 JSON(不要输出任何多余文字):
{
"overall_score": <int>,
"pass": <bool>,
"issues": [
{"page": <int>, "issue_type": "<type>", "severity": "<high|medium|low>", "suggestion": "<中文建议>"}
]
}"""
class Reviewer:
"""审核者 Agent:看最新一版渲染截图,输出结构化 JSON 建议。每轮独立调用、无历史。"""
def __init__(self, meter: TokenMeter):
self.client = _client()
self.meter = meter
def review(self, png_paths: list[str]) -> dict:
content = [{"type": "text",
"text": f"这份 PPT 共 {len(png_paths)} 页,下面按页码顺序给出每一页的渲染截图。请审查。"}]
for i, p in enumerate(png_paths, 1):
content.append({"type": "text", "text": f"第 {i} 页:"})
content.append({"type": "image_url",
"image_url": {"url": encode_image(p), "detail": "high"}})
resp = self.client.chat.completions.create(
model=VISION_MODEL,
messages=[
{"role": "system", "content": REVIEW_RUBRIC},
{"role": "user", "content": content},
],
temperature=0.2,
)
self.meter.add(resp.usage)
return _extract_json(resp.choices[0].message.content)
PROPOSER_SYSTEM = """你是一名擅长把学术论文转化为演示文稿的 Proposer Agent。
你用 Slidev 框架(Markdown + HTML)编写 PPT 源码 slides.md。
Slidev 语法要点:
- 文件开头是 YAML frontmatter(--- 包裹),设置 theme: default。
- 用单独一行的 `---`(前后空行)分隔每一页幻灯片。
- 首页通常放标题、作者、会议。
- 引用图片用 markdown:,可用 HTML 控制尺寸,
例如 <img src="/speedup_bar.png" class="h-60 mx-auto" />。
- 可用 Windi/Uno CSS 工具类控制排版(如 text-sm、grid grid-cols-2 gap-4)。
要求:
- 生成约 8-12 页,覆盖论文的标题、背景/动机、方法、实验结果、结论。
- 至少在 3 页中使用提供的图表/表格,且图文匹配。
- 每页信息量适中、不要塞太多字,宁可拆页也不要溢出。
- 只输出 slides.md 的完整内容,用 ```markdown 代码块包裹,不要额外解释。"""
class Proposer:
"""提议者 Agent:只吃文本(论文 + 累积文字反馈),产出 slides.md。"""
def __init__(self, meter: TokenMeter, paper_md: str, figures: dict):
self.client = _client()
self.meter = meter
fig_desc = "\n".join(f"- {name}:{desc}" for name, desc in figures.items())
first_user = (
f"以下是论文全文(Markdown):\n\n{paper_md}\n\n"
f"可直接引用的图表文件(放在 Slidev public 目录,用 /文件名 引用):\n{fig_desc}\n\n"
f"请先做一版**快速初稿**:为了尽快出稿,把整篇论文压缩到 **4 页以内**——"
f"直接把每个章节对应的**完整段落原文**成段贴到幻灯片上(保留整段文字,先不要精简成要点),"
f"并把两张图表也放进去。(后续会有审核者看真实渲染效果再帮你调整。)生成完整的 slides.md。"
)
# Proposer 的对话历史——只累积文本,永不加入图片
self.messages = [
{"role": "system", "content": PROPOSER_SYSTEM},
{"role": "user", "content": first_user},
]
def _generate(self) -> str:
resp = self.client.chat.completions.create(
model=TEXT_MODEL, messages=self.messages, temperature=0.3,
)
self.meter.add(resp.usage)
reply = resp.choices[0].message.content
self.messages.append({"role": "assistant", "content": reply})
return _extract_slides_md(reply)
def propose(self) -> str:
"""首轮生成。"""
return self._generate()
def revise(self, review: dict) -> str:
"""根据 Reviewer 的结构化文字反馈修订(只把 JSON 文本加入上下文)。"""
feedback = json.dumps(review, ensure_ascii=False, indent=2)
self.messages.append({
"role": "user",
"content": (
"审核者(Reviewer)渲染了你上一版 slides.md 的每一页截图,"
"给出如下结构化改进建议(JSON):\n\n"
f"{feedback}\n\n"
"请理解这些问题并修订 slides.md(可拆页、精简文字、调整图片尺寸等),"
"重新输出完整的 slides.md。"
),
})
return self._generate()
# --------------------------------------------------------------------------- #
# 单 Agent 自审对照组:同一段对话里既生成、又看自己的渲染图、又修订。
# 关键区别:历次渲染的图片会**留在**同一上下文里,导致上下文随迭代快速膨胀。
# --------------------------------------------------------------------------- #
SELF_REVIEW_SYSTEM = PROPOSER_SYSTEM + """
此外,你还要**自我审查**:当收到自己 PPT 的渲染截图时,先在心里按下列标准找出问题
(文字溢出、内容拥挤、图片尺寸、可读性、布局),再据此输出修订后的完整 slides.md。"""
class SelfReviewAgent:
"""单 Agent 自审:一条不断增长的对话,图片累积在上下文中。"""
def __init__(self, meter: TokenMeter, paper_md: str, figures: dict):
self.client = _client()
self.meter = meter
fig_desc = "\n".join(f"- {name}:{desc}" for name, desc in figures.items())
first_user = (
f"以下是论文全文(Markdown):\n\n{paper_md}\n\n"
f"可直接引用的图表文件:\n{fig_desc}\n\n"
f"请先做一版**快速初稿**:为了尽快出稿,把整篇论文压缩到 **4 页以内**——"
f"直接把每个章节对应的**完整段落原文**成段贴到幻灯片上(保留整段文字,先不要精简成要点),"
f"并把两张图表也放进去。(之后你会看到真实渲染截图再据此调整。)生成完整的 slides.md。"
)
self.messages = [
{"role": "system", "content": SELF_REVIEW_SYSTEM},
{"role": "user", "content": first_user},
]
def propose(self) -> str:
resp = self.client.chat.completions.create(
model=VISION_MODEL, messages=self.messages, temperature=0.3,
)
self.meter.add(resp.usage)
reply = resp.choices[0].message.content
self.messages.append({"role": "assistant", "content": reply})
return _extract_slides_md(reply)
def self_review_and_revise(self, png_paths: list[str]) -> str:
"""把最新渲染截图加入**同一**上下文,让模型自审并修订。图片会一直留在历史里。"""
content = [{"type": "text",
"text": (f"这是你上一版 slides.md 渲染出的 {len(png_paths)} 页截图。"
"请自我审查(文字溢出/拥挤/图片尺寸/可读性/布局),"
"然后输出修订后的完整 slides.md。")}]
for i, p in enumerate(png_paths, 1):
content.append({"type": "text", "text": f"第 {i} 页:"})
content.append({"type": "image_url",
"image_url": {"url": encode_image(p), "detail": "high"}})
self.messages.append({"role": "user", "content": content})
resp = self.client.chat.completions.create(
model=VISION_MODEL, messages=self.messages, temperature=0.3,
)
self.meter.add(resp.usage)
reply = resp.choices[0].message.content
self.messages.append({"role": "assistant", "content": reply})
return _extract_slides_md(reply)
def independent_judge(png_paths: list[str], meter: TokenMeter) -> dict:
"""用同一套 rubric、独立地给某一版最终 PPT 打分,用于公平比较两种方案的质量。"""
reviewer = Reviewer(meter)
return reviewer.review(png_paths)
demo.py¶
"""
实验 5-4:基于论文的 PPT 自动生成(提议者-审核者机制)
完整流程:
1. 从精简论文(paper/sample_paper.md)+ 程序化复现的图表出发;
2. 【双 Agent】Proposer 生成 slides.md → Slidev 渲染每页 PNG → Reviewer 用 Vision LLM
看图给出结构化建议 → Proposer 据反馈修订 → 迭代,直到 pass 或达最大轮数;
3. 【单 Agent 自审】同一个 Agent 生成 → 渲染 → 把自己的截图塞回**同一上下文**自审并修订 → 迭代;
4. 用同一位“独立评委”(Vision)给两种方案的最终 PPT 打分,公平比较**质量**;
5. 打印两种方案的**上下文 token 消耗**对比(总量、峰值单次 prompt token)。
运行:python demo.py # 完整对比(两种方案)
python demo.py --help # 查看全部参数
python demo.py --mode dual --max-rounds 1 # 快速:只跑双 Agent、只出首版
python demo.py --smoke # 仅验证 Slidev 渲染链路,不调用任何 LLM
python demo.py --dry-run # 离线走通提议者-审核者循环(真实渲染 + 脚本化改稿)
依赖:Node/Slidev(渲染)、OPENAI_API_KEY(gpt-5.6-luna 视觉 + 文本;未配置时可用 OPENROUTER_API_KEY 兜底)。
"""
import argparse
import json
import os
import re
import sys
from dotenv import load_dotenv
load_dotenv()
import agents # noqa: E402 —— 用模块名引用 TEXT_MODEL/VISION_MODEL,便于 CLI 覆盖
from agents import ( # noqa: E402
Proposer, Reviewer, SelfReviewAgent, TokenMeter, independent_judge,
)
from make_figures import generate_all # noqa: E402
from renderer import render_slides # noqa: E402
HERE = os.path.dirname(os.path.abspath(__file__))
DEFAULT_PAPER_PATH = os.path.join(HERE, "paper", "sample_paper.md")
DEFAULT_OUT_DIR = os.path.join(HERE, "output")
OUT_DIR = DEFAULT_OUT_DIR # 可被 --out-dir 覆盖(main 内 global 赋值)
MAX_ROUNDS = 3 # 每种方案的最大迭代轮数(首轮 + 最多 2 轮修订)
def banner(title):
print("\n" + "=" * 74)
print(f" {title}")
print("=" * 74)
def save_text(name, text):
os.makedirs(OUT_DIR, exist_ok=True)
path = os.path.join(OUT_DIR, name)
with open(path, "w", encoding="utf-8") as f:
f.write(text)
return path
def _review_issues(review: dict) -> list:
"""JSON null issues must behave like omit ([])."""
issues = review.get("issues")
return issues if issues is not None else []
def summarize_review(review: dict) -> str:
n_high = sum(1 for x in _review_issues(review) if x.get("severity") == "high")
n_med = sum(1 for x in _review_issues(review) if x.get("severity") == "medium")
n_low = sum(1 for x in _review_issues(review) if x.get("severity") == "low")
return (f"score={review.get('overall_score')} pass={review.get('pass')} "
f"issues={len(_review_issues(review))} (high={n_high}, med={n_med}, low={n_low})")
# --------------------------------------------------------------------------- #
# 方案 A:提议者-审核者(双 Agent)
# --------------------------------------------------------------------------- #
def run_proposer_reviewer(paper_md, figures, max_rounds=MAX_ROUNDS):
banner("方案 A:提议者-审核者(双 Agent 分工)")
proposer_meter = TokenMeter("Proposer(纯文本)")
reviewer_meter = TokenMeter("Reviewer(每轮只看最新截图)")
proposer = Proposer(proposer_meter, paper_md, figures)
reviewer = Reviewer(reviewer_meter)
history = [] # 每轮的 (score, review)
slides = proposer.propose()
final_pngs = None
for rnd in range(1, max_rounds + 1):
print(f"\n[双 Agent] 第 {rnd} 轮:Proposer 产出 slides.md({slides.count(chr(10) + '---' + chr(10)) + 1} 段分隔)")
md_path = save_text(f"dual_round{rnd}_slides.md", slides)
pngs = render_slides(slides, f"dual_round{rnd}")
final_pngs = pngs
print(f" 渲染出 {len(pngs)} 页 PNG,例如:{pngs[0]}")
review = reviewer.review(pngs)
print(f" Reviewer(Vision)审查:{summarize_review(review)}")
# 打印真实的建议 JSON(前几条)
print(" Reviewer 结构化建议 JSON:")
print(_indent(json.dumps(review, ensure_ascii=False, indent=2), 4))
save_text(f"dual_round{rnd}_review.json",
json.dumps(review, ensure_ascii=False, indent=2))
history.append((review.get("overall_score", 0), review))
blocking = [i for i in _review_issues(review)
if i.get("severity") in ("high", "medium")]
if review.get("pass") and not blocking:
print(" ✓ Reviewer 判定达标(无 high/medium 问题),提前结束迭代。")
break
if rnd == max_rounds:
break
print(" → Proposer 接收结构化文字反馈并修订(上下文只增文本,不含图片)")
slides = proposer.revise(review)
return {
"slides": slides,
"final_pngs": final_pngs,
"history": history,
"proposer_meter": proposer_meter,
"reviewer_meter": reviewer_meter,
}
# --------------------------------------------------------------------------- #
# 方案 B:单 Agent 自审
# --------------------------------------------------------------------------- #
def run_single_agent(paper_md, figures, max_rounds=MAX_ROUNDS):
banner("方案 B:单 Agent 自我审查(图片累积在同一上下文)")
meter = TokenMeter("SingleAgent(自审, 图片累积)")
agent = SelfReviewAgent(meter, paper_md, figures)
slides = agent.propose()
final_pngs = None
for rnd in range(1, max_rounds + 1):
print(f"\n[单 Agent] 第 {rnd} 轮:生成/修订 slides.md")
save_text(f"single_round{rnd}_slides.md", slides)
pngs = render_slides(slides, f"single_round{rnd}")
final_pngs = pngs
print(f" 渲染出 {len(pngs)} 页 PNG")
print(f" 当前上下文峰值 prompt token = {meter.peak_prompt_tokens}")
if rnd == max_rounds:
break
print(" → 把 %d 张截图塞回同一上下文,Agent 自审并修订(历史图片不清除)" % len(pngs))
slides = agent.self_review_and_revise(pngs)
return {"slides": slides, "final_pngs": final_pngs, "meter": meter}
def _indent(text, n):
pad = " " * n
return "\n".join(pad + line for line in text.splitlines())
def smoke_test():
"""快速冒烟:只验证 Slidev 渲染链路是否可用,不调用任何 LLM,无需 API Key。"""
from renderer import render_slides
banner("Smoke test:仅验证 Slidev 渲染链路(不调用 LLM)")
demo_md = (
"---\ntheme: default\n---\n\n"
"# Smoke Test\n\n渲染链路自检\n\n---\n\n"
"# 第二页\n\n- Slidev + playwright-chromium 正常\n"
)
pngs = render_slides(demo_md, "smoke")
print(f"✓ 渲染成功,产出 {len(pngs)} 页 PNG:")
for p in pngs:
print(" ", p)
print("Slidev 渲染链路可用。")
# --------------------------------------------------------------------------- #
# 离线 dry-run:不调用任何 LLM,走通提议者-审核者循环的**结构**。
# - Proposer 的两版稿件是脚本化的(拥挤初稿 → 拆页修订稿),而非 LLM 生成;
# - 渲染是**真实**的(真的调 Slidev 导出 PNG);
# - Reviewer 用**确定性启发式规则**(按每页文字量判定 overcrowded),
# 明确不是 Vision LLM——仅用于离线演示“生成→渲染→审查→修订”的闭环。
# 真实的 Vision 审查请用 `python demo.py`(需 OPENAI_API_KEY)。
# --------------------------------------------------------------------------- #
def _split_paragraphs(paper_md: str) -> list[str]:
"""按空行切出正文段落,剔除标题行与表格/图片,供脚本化排版使用。"""
paras = []
for block in re.split(r"\n\s*\n", paper_md):
block = block.strip()
if not block or block.startswith("#") or block.startswith("|"):
continue
paras.append(re.sub(r"\s+", " ", block))
return paras
def _paper_title(paper_md: str) -> str:
m = re.search(r"^#\s+(.+)$", paper_md, re.MULTILINE)
return m.group(1).strip() if m else "论文演示"
def _dry_first_draft(paper_md: str, figures: dict) -> str:
"""脚本化“拥挤初稿”:把整篇论文压进约 4 页,每页塞多段原文(必然溢出)。"""
title = _paper_title(paper_md)
paras = _split_paragraphs(paper_md) or ["(论文正文为空)"]
fig_names = list(figures.keys())
# 把段落尽量塞进 3 张内容页
groups, per = [], max(1, (len(paras) + 2) // 3)
for i in range(0, len(paras), per):
groups.append(paras[i:i + per])
pages = [f"---\ntheme: default\n---\n\n# {title}\n\n自动生成演示(离线 dry-run 初稿)"]
for gi, g in enumerate(groups[:3]):
body = "\n\n".join(g)
img = f"\n\n" if gi < len(fig_names) else ""
pages.append(f"# 第 {gi + 1} 部分\n\n{body}{img}")
return "\n\n---\n\n".join(pages) + "\n"
def _dry_revised(paper_md: str, figures: dict) -> str:
"""脚本化“修订稿”:一段一页、要点化,图表单独成页——明显更宽松,可通过启发式。"""
title = _paper_title(paper_md)
paras = _split_paragraphs(paper_md) or ["(论文正文为空)"]
fig_names = list(figures.keys())
pages = [f"---\ntheme: default\n---\n\n# {title}\n\n自动生成演示(离线 dry-run 修订稿)"]
for i, para in enumerate(paras):
# 每页只放一段,且截断到约 220 字,模拟“精简成要点”
text = para if len(para) <= 220 else para[:210].rstrip() + "……"
pages.append(f"# 要点 {i + 1}\n\n{text}")
for name in fig_names: # 图表各自单独成页,尺寸受控
pages.append(f"# 图表\n\n<img src=\"{name}\" class=\"h-80 mx-auto\" />")
return "\n\n---\n\n".join(pages) + "\n"
def _heuristic_review(slides_md: str) -> dict:
"""确定性启发式(非 Vision LLM):按每页正文字符数判定 overcrowded。"""
parts = re.split(r"(?m)^---\s*$", slides_md)
pages, page_no = [], 0
for part in parts:
s = part.strip()
if not s or s.startswith("theme:") or "theme:" in s.split("\n")[0]:
continue
pages.append(s)
issues = []
for idx, page in enumerate(pages, 1):
text = re.sub(r"!\[.*?\]\(.*?\)|<img[^>]*>", "", page) # 不计图片
n = len(re.sub(r"\s+", "", text))
if n > 500:
issues.append({"page": idx, "issue_type": "overcrowded", "severity": "high",
"suggestion": f"该页正文约 {n} 字,严重溢出,建议拆成多页并精简为要点。"})
elif n > 300:
issues.append({"page": idx, "issue_type": "overcrowded", "severity": "medium",
"suggestion": f"该页正文约 {n} 字,偏挤,建议拆页或删减。"})
blocking = [i for i in issues if i["severity"] in ("high", "medium")]
score = max(0, 100 - 15 * len(blocking) - 3 * (len(issues) - len(blocking)))
return {"overall_score": score, "pass": not blocking, "issues": issues,
"_reviewer": "heuristic (offline, NOT a Vision LLM)"}
def dry_run(paper_path: str):
"""离线走通提议者-审核者循环:真实渲染 + 脚本化改稿 + 启发式审查。"""
banner("Dry-run:离线演示提议者-审核者循环(真实渲染,脚本化改稿,启发式审查)")
if not os.path.exists(paper_path):
print(f"找不到论文文件:{paper_path}")
sys.exit(1)
with open(paper_path, encoding="utf-8") as f:
paper_md = f.read()
figures = generate_all()
print(f"论文:{paper_path}({len(paper_md)} 字符);已复现图表:{', '.join(figures)}")
print("注意:本模式不调用任何 LLM。Reviewer 由确定性启发式规则扮演(非 Vision LLM),")
print(" 仅用于离线展示“生成→渲染→审查→修订”的闭环;真实 Vision 审查请用 `python demo.py`。")
stages = [
("拥挤初稿", _dry_first_draft(paper_md, figures)),
("拆页修订稿", _dry_revised(paper_md, figures)),
]
last_review = None
for rnd, (label, slides) in enumerate(stages, 1):
n_pages = slides.count("\n---\n") # 页分隔符数量≈页数
print(f"\n[dry-run] 第 {rnd} 轮:Proposer 产出 slides.md({label},约 {n_pages} 页)")
save_text(f"dryrun_round{rnd}_slides.md", slides)
pngs = render_slides(slides, f"dryrun_round{rnd}")
print(f" 渲染出 {len(pngs)} 页 PNG,例如:{pngs[0]}")
review = _heuristic_review(slides)
print(f" Reviewer(启发式)审查:{summarize_review(review)}")
print(" Reviewer 结构化建议 JSON:")
print(_indent(json.dumps(review, ensure_ascii=False, indent=2), 4))
save_text(f"dryrun_round{rnd}_review.json",
json.dumps(review, ensure_ascii=False, indent=2))
last_review = review
if review["pass"]:
print(" ✓ Reviewer 判定达标(无 high/medium 问题),闭环结束。")
break
if rnd < len(stages):
print(" → Proposer 接收结构化文字反馈并修订(拆页、精简;此处为脚本化改稿)")
banner("Dry-run 小结")
print(f"闭环演示完成:初稿被判定拥挤 → 修订稿 pass={last_review['pass']}"
f"(启发式打分 {last_review['overall_score']})。")
print(f"真实渲染 PNG:slidev_workspace/exports/dryrun_round*/")
print(f"脚本化 slides.md 与审查 JSON:{OUT_DIR}/dryrun_round*")
print("真实的 Vision 审查循环(gpt-5.6-luna 看像素)请运行:python demo.py --mode dual --max-rounds 3")
def parse_args(argv=None):
p = argparse.ArgumentParser(
prog="demo.py",
description="实验 5-4:论文 → PPT 自动生成(提议者-审核者 vs 单 Agent 自审对照)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"示例:\n"
" python demo.py # 完整对比:两种方案 + 独立评委 + token 对比\n"
" python demo.py --mode dual # 只跑双 Agent(省一半时间/费用)\n"
" python demo.py --max-rounds 1 # 每种方案只出首版(最快的真实 LLM 冒烟)\n"
" python demo.py --paper my.md --out-dir run1 # 换论文、换输出目录\n"
" python demo.py --vision-model gpt-5.6-luna # 覆盖视觉模型\n"
" python demo.py --dry-run # 离线走通提议者-审核者循环,不调用任何 LLM\n"
" python demo.py --smoke # 仅验证 Slidev 渲染,不调用任何 LLM\n\n"
"模型/供应商也可通过环境变量配置(见 env.example);命令行 --text-model /\n"
"--vision-model 优先级更高:OPENAI_API_KEY / OPENAI_BASE_URL / TEXT_MODEL / VISION_MODEL"
),
)
p.add_argument("--paper", metavar="PATH", default=DEFAULT_PAPER_PATH,
help="输入论文的 Markdown 路径(默认 paper/sample_paper.md)。"
"替换为你自己的论文即可;保留章节结构即可被 Proposer 解析。")
p.add_argument("--out-dir", metavar="DIR", default=DEFAULT_OUT_DIR,
help="产物输出目录:各轮 slides.md / review.json / comparison_summary.json "
"(默认 output/)。渲染 PNG 始终位于 slidev_workspace/exports/。")
p.add_argument("--text-model", metavar="NAME", default=None,
help="Proposer / 单 Agent 文本部分用的模型,覆盖 TEXT_MODEL 环境变量"
f"(默认 {agents.TEXT_MODEL})。")
p.add_argument("--vision-model", metavar="NAME", default=None,
help="Reviewer / 独立评委看图用的模型,必须支持图像输入,覆盖 VISION_MODEL "
f"环境变量(默认 {agents.VISION_MODEL})。")
p.add_argument("--mode", choices=["both", "dual", "single"], default="both",
help="运行哪种方案:both=两种都跑并对比(默认);dual=仅提议者-审核者;"
"single=仅单 Agent 自审。只跑一种可显著省时省钱。")
p.add_argument("--max-rounds", type=int, default=MAX_ROUNDS, metavar="N",
help=f"每种方案的最大迭代轮数(默认 {MAX_ROUNDS})。设为 1 即只出首版、"
"不修订,是最快的真实运行冒烟。")
p.add_argument("--dry-run", action="store_true",
help="离线演示提议者-审核者循环:真实渲染两版脚本化 slides.md(拥挤初稿→"
"拆页修订稿),用启发式规则(非 Vision LLM)扮演 Reviewer,展示"
"生成→渲染→审查→修订的闭环结构。不调用任何 LLM,无需 API Key。")
p.add_argument("--smoke", action="store_true",
help="仅验证 Slidev 渲染链路(渲染一个两页 deck),不调用任何 LLM,无需 API Key。")
return p.parse_args(argv)
def _save_partial_summary(dual, dual_final, single, single_final):
"""单方案运行(--mode dual/single)时,落盘该方案自身的质量与 token 结果。"""
summary = {"models": {"text": agents.TEXT_MODEL, "vision": agents.VISION_MODEL}}
if dual:
pm, rm = dual["proposer_meter"], dual["reviewer_meter"]
summary["dual_agent"] = {
"iteration_scores": [h[0] for h in dual["history"]],
"final_quality": dual_final,
"total_tokens": pm.total_tokens + rm.total_tokens,
"peak_context_prompt_tokens": max(pm.peak_prompt_tokens, rm.peak_prompt_tokens),
}
if single:
sm = single["meter"]
summary["single_agent"] = {
"final_quality": single_final,
"total_tokens": sm.total_tokens,
"peak_context_prompt_tokens": sm.peak_prompt_tokens,
}
p = save_text("comparison_summary.json", json.dumps(summary, ensure_ascii=False, indent=2))
print(f"\n结果已保存:{p}")
print(f"所有 slides.md / review.json / 渲染 PNG 位于:{OUT_DIR}/ 与 slidev_workspace/exports/")
def main(argv=None):
global OUT_DIR
args = parse_args(argv)
# 输出目录(--out-dir):所有 save_text 都写到这里
OUT_DIR = os.path.abspath(args.out_dir)
# 模型覆盖(--text-model / --vision-model 优先于环境变量)
if args.text_model:
agents.TEXT_MODEL = args.text_model
if args.vision_model:
agents.VISION_MODEL = args.vision_model
if args.smoke:
smoke_test()
return
if args.dry_run:
dry_run(args.paper)
return
if args.max_rounds < 1:
print("--max-rounds 至少为 1")
sys.exit(1)
if not os.path.exists(args.paper):
print(f"找不到论文文件:{args.paper}(用 --paper 指定,或参考默认 paper/sample_paper.md)")
sys.exit(1)
if not (os.environ.get("OPENAI_API_KEY") or os.environ.get("OPENROUTER_API_KEY")):
print("请先设置 OPENAI_API_KEY(或 OPENROUTER_API_KEY 兜底,可参考 env.example)")
sys.exit(1)
banner("准备:论文 + 程序化复现的图表")
with open(args.paper, encoding="utf-8") as f:
paper_md = f.read()
figures = generate_all()
print(f"论文:{args.paper}({len(paper_md)} 字符)")
print(f"输出目录:{OUT_DIR}")
print(f"文本模型:{agents.TEXT_MODEL} 视觉模型:{agents.VISION_MODEL}")
print(f"运行模式:{args.mode} 最大轮数:{args.max_rounds}")
print("已生成图表:")
for k, v in figures.items():
print(f" {k} -> {v}")
# 方案 A / 方案 B(--mode 控制跑哪一种;只有 both 才能做跨方案对比)
dual = run_proposer_reviewer(paper_md, figures, args.max_rounds) \
if args.mode in ("both", "dual") else None
single = run_single_agent(paper_md, figures, args.max_rounds) \
if args.mode in ("both", "single") else None
# ------- 用同一位独立评委给两种方案的最终 PPT 打分(质量对比,尽量公平) -------
banner("独立评委:对最终 PPT 打分(同一 Vision rubric)")
judge_meter = TokenMeter("独立评委(不计入两方案成本)")
dual_final = independent_judge(dual["final_pngs"], judge_meter) if dual else None
single_final = independent_judge(single["final_pngs"], judge_meter) if single else None
if dual_final:
print(f"方案 A(双 Agent)最终质量:{summarize_review(dual_final)}")
if single_final:
print(f"方案 B(单 Agent)最终质量:{summarize_review(single_final)}")
if not (dual and single):
# 单方案运行:跳过跨方案的 token 对比,仅落盘已有结果
_save_partial_summary(dual, dual_final, single, single_final)
return
# ------- 迭代改善情况(双 Agent) -------
banner("迭代质量改善(方案 A:提议者-审核者)")
scores = [h[0] for h in dual["history"]]
if len(scores) >= 2:
print(f"Reviewer 打分随迭代变化:{scores} "
f"({'↑ 改善' if scores[-1] >= scores[0] else '↓'} {scores[-1] - scores[0]:+d})")
else:
print(f"仅 1 轮即达标,Reviewer 打分:{scores}")
# ------- 上下文 token 消耗对比 -------
banner("上下文 Token 消耗对比:单 Agent 自审 vs 提议者-审核者")
pm, rm, sm = dual["proposer_meter"], dual["reviewer_meter"], single["meter"]
dual_total = pm.total_tokens + rm.total_tokens
dual_peak = max(pm.peak_prompt_tokens, rm.peak_prompt_tokens)
def row(label, calls, prompt, completion, total, peak):
print(f" {label:<34} calls={calls:<3} prompt={prompt:<8} "
f"completion={completion:<7} total={total:<8} peak_ctx={peak}")
print("双 Agent(方案 A)拆分:")
row(pm.name, pm.calls, pm.prompt_tokens, pm.completion_tokens, pm.total_tokens, pm.peak_prompt_tokens)
row(rm.name, rm.calls, rm.prompt_tokens, rm.completion_tokens, rm.total_tokens, rm.peak_prompt_tokens)
print("-" * 74)
row("【方案 A 合计】", pm.calls + rm.calls, pm.prompt_tokens + rm.prompt_tokens,
pm.completion_tokens + rm.completion_tokens, dual_total, dual_peak)
row("【方案 B 单Agent自审】", sm.calls, sm.prompt_tokens, sm.completion_tokens,
sm.total_tokens, sm.peak_prompt_tokens)
print("-" * 74)
print(f"每次调用的 prompt token 序列:")
print(f" 方案A Proposer : {pm.per_call_prompt}")
print(f" 方案A Reviewer : {rm.per_call_prompt} ← 每轮独立、只看最新截图,不随迭代累积")
print(f" 方案B 单Agent : {sm.per_call_prompt} ← 图片累积在同一上下文,峰值随迭代上升")
print()
print(f"关键结论:")
print(f" · 上下文峰值(单次 prompt token,决定是否撑爆上下文窗口):")
print(f" 方案 A = {dual_peak} 方案 B = {sm.peak_prompt_tokens} "
f"(B/A = {sm.peak_prompt_tokens / max(dual_peak,1):.2f}x)")
print(f" · Proposer 全程不看图片,其峰值仅 {pm.peak_prompt_tokens} token(纯文本反馈)。")
print(f" · 方案 B 因图片在同一上下文累积,峰值最高;页数越多、迭代越多,差距越大。")
# 汇总落盘
summary = {
"models": {"text": agents.TEXT_MODEL, "vision": agents.VISION_MODEL},
"dual_agent": {
"iteration_scores": scores,
"final_quality": dual_final,
"proposer_tokens": pm.__dict__,
"reviewer_tokens": rm.__dict__,
"total_tokens": dual_total,
"peak_context_prompt_tokens": dual_peak,
},
"single_agent": {
"final_quality": single_final,
"tokens": sm.__dict__,
"total_tokens": sm.total_tokens,
"peak_context_prompt_tokens": sm.peak_prompt_tokens,
},
}
p = save_text("comparison_summary.json", json.dumps(summary, ensure_ascii=False, indent=2))
print(f"\n完整对比已保存:{p}")
print(f"所有 slides.md / review.json / 渲染 PNG 位于:{OUT_DIR}/ 与 slidev_workspace/exports/")
if __name__ == "__main__":
main()
make_figures.py¶
"""
从论文中的数据生成两张真实的图表 PNG,放进 Slidev 的 public/ 目录,
供 Proposer 生成的幻灯片直接引用(满足“至少 3 处原图表”的要求,同时
让 Reviewer 的 Vision 检查能真正评估“图片尺寸是否合适”)。
这些图是用 matplotlib 从论文正文里的数字画出来的,属于“论文原始图表”的
程序化复现,而非凭空捏造。
"""
import os
import matplotlib
matplotlib.use("Agg") # 无显示环境
import matplotlib.pyplot as plt
PUBLIC_DIR = os.path.join(os.path.dirname(__file__), "slidev_workspace", "public")
def make_speedup_bar(path):
"""图1:FlashAttention 相对标准实现的端到端加速比(论文第 4 节数字)。"""
labels = ["BERT-large\n(seq 512)", "GPT-2\n(seq 1K)", "Long-Range\nArena"]
speedups = [1.15, 3.0, 2.4]
fig, ax = plt.subplots(figsize=(6, 3.4), dpi=150)
bars = ax.bar(labels, speedups, color=["#4C72B0", "#DD8452", "#55A868"])
ax.axhline(1.0, color="gray", linestyle="--", linewidth=1, label="baseline (1x)")
ax.set_ylabel("Speedup vs. standard")
ax.set_title("FlashAttention End-to-End Speedup")
for b, v in zip(bars, speedups):
ax.text(b.get_x() + b.get_width() / 2, v + 0.05, f"{v}x",
ha="center", va="bottom", fontweight="bold")
ax.set_ylim(0, 3.5)
ax.legend(loc="upper left", fontsize=8)
fig.tight_layout()
fig.savefig(path, bbox_inches="tight")
plt.close(fig)
def make_memory_hierarchy(path):
"""图2:GPU 内存层次的带宽对比(论文第 2 节表格,对数坐标)。"""
levels = ["SRAM\n(on-chip)", "HBM\n(main GPU)", "CPU DRAM"]
bandwidth = [19000, 1750, 12.8] # GB/s
fig, ax = plt.subplots(figsize=(6, 3.4), dpi=150)
bars = ax.bar(levels, bandwidth, color=["#C44E52", "#8172B3", "#937860"])
ax.set_yscale("log")
ax.set_ylabel("Bandwidth (GB/s, log scale)")
ax.set_title("GPU Memory Hierarchy (A100)")
for b, v in zip(bars, bandwidth):
ax.text(b.get_x() + b.get_width() / 2, v * 1.15,
f"{v:g}", ha="center", va="bottom", fontweight="bold")
fig.tight_layout()
fig.savefig(path, bbox_inches="tight")
plt.close(fig)
def generate_all():
os.makedirs(PUBLIC_DIR, exist_ok=True)
f1 = os.path.join(PUBLIC_DIR, "speedup_bar.png")
f2 = os.path.join(PUBLIC_DIR, "memory_hierarchy.png")
make_speedup_bar(f1)
make_memory_hierarchy(f2)
return {
"/speedup_bar.png": "FlashAttention 端到端加速比柱状图(BERT 1.15x / GPT-2 3x / LRA 2.4x)",
"/memory_hierarchy.png": "A100 GPU 内存层次带宽对比(SRAM 19TB/s / HBM ~1.75TB/s / DRAM 12.8GB/s,对数坐标)",
}
if __name__ == "__main__":
print(generate_all())
renderer.py¶
"""
Slidev 渲染器:把 Proposer 生成的 slides.md 真正渲染成“每页一张 PNG”。
这是本实验的关键——Reviewer 之所以能看到 Proposer 看不到的“新信息”,
正是因为我们真的把代码跑起来、渲染出了像素级的截图。
实现:调用本地安装的 Slidev CLI
npx slidev export slides.md --format png --output <dir> --timeout 60000
Slidev 的 PNG 导出底层用 playwright-chromium 打开每一页并截图。
"""
import glob
import os
import shutil
import subprocess
WORKSPACE = os.path.join(os.path.dirname(__file__), "slidev_workspace")
class RenderError(RuntimeError):
pass
def render_slides(slides_md: str, out_subdir: str) -> list[str]:
"""
将 slides_md 文本写入工作区并导出为逐页 PNG。
参数:
slides_md: 完整的 Slidev markdown 源码
out_subdir: 输出子目录名(如 'proposer_iter1')
返回:
按页码排序的 PNG 绝对路径列表。
"""
os.makedirs(WORKSPACE, exist_ok=True)
slides_path = os.path.join(WORKSPACE, "slides.md")
with open(slides_path, "w", encoding="utf-8") as f:
f.write(slides_md)
out_dir = os.path.join(WORKSPACE, "exports", out_subdir)
if os.path.exists(out_dir):
shutil.rmtree(out_dir)
os.makedirs(out_dir, exist_ok=True)
# Slidev 的 PNG 导出会在 output 目录下生成 1.png, 2.png ...
cmd = [
"npx", "--no-install", "slidev", "export", "slides.md",
"--format", "png",
"--output", out_dir,
"--timeout", "60000",
"--dark", "false",
]
env = dict(os.environ)
# 让 playwright 使用项目内安装的 chromium(package.json 里的 playwright-chromium)
proc = subprocess.run(
cmd, cwd=WORKSPACE, env=env,
capture_output=True, text=True, timeout=600,
)
if proc.returncode != 0:
raise RenderError(
"Slidev export 失败:\n"
f"stdout:\n{proc.stdout[-2000:]}\n"
f"stderr:\n{proc.stderr[-2000:]}"
)
pngs = sorted(
glob.glob(os.path.join(out_dir, "*.png")),
key=lambda p: _page_num(p),
)
if not pngs:
raise RenderError(
f"Slidev export 未产出 PNG。stdout:\n{proc.stdout[-2000:]}"
)
return pngs
def _page_num(path: str) -> int:
base = os.path.splitext(os.path.basename(path))[0]
digits = "".join(ch for ch in base if ch.isdigit())
return int(digits) if digits else 0
if __name__ == "__main__":
# 自检:渲染一个最小的两页 slidev
demo = """---
theme: default
---
# Hello Slidev
第一页
---
# 第二页
- 渲染自检成功
"""
paths = render_slides(demo, "selftest")
print("渲染出的 PNG:")
for p in paths:
print(" ", p)
test_null_issues.py¶
"""Null review issues must summarize without TypeError."""
import sys
from unittest.mock import MagicMock
# demo.py imports heavy optional deps at module import time.
sys.modules.setdefault("dotenv", MagicMock())
sys.modules.setdefault("agents", MagicMock())
sys.modules.setdefault("make_figures", MagicMock())
sys.modules.setdefault("renderer", MagicMock())
from demo import summarize_review, _review_issues
def test_null_issues_like_empty():
assert _review_issues({"issues": None}) == []
text = summarize_review({"overall_score": 90, "pass": True, "issues": None})
assert "issues=0" in text
assert "high=0" in text
def test_issues_preserved():
issues = [{"severity": "high"}]
assert _review_issues({"issues": issues}) == issues
text = summarize_review({"overall_score": 50, "pass": False, "issues": issues})
assert "high=1" in text