论文资讯作为调用方
主线位置:「一个请求的一生」开始之前,发请求的一方。调用方是本站框架里的 Rust 程序,用
reqwest发 OpenAI 兼容的/v1/chat/completions请求。现在接的是托管 API;换成自托管 vLLM 或 SGLang 时,请求依次经过两个引擎的前端(默认 Python,可选 Rust)、调度器和 KV 管理,见 01 到 03 部分。
版本:vLLM v0.32.0、SGLang v0.5.22;本站框架 commit
705d8a2b7f38(开源仓库main)。
结论:
- 做什么:每天定时跑一次,取前一天 Hugging Face Daily Papers 的列表,按点赞数取前 N 篇(默认 20)逐篇生成。每篇至少两次模型调用:全文 → 英文 JSON 解读;英文 JSON → 中文。每次校验不过就带上问题重试一次,所以每篇 2 到 4 次。篇与篇、调用与调用之间全部串行。
- 多大:线上两次采集的汇总,17 篇,平均每篇输入约 17,907、输出约 12,444 token,每篇约 60 秒。每次调用的长度没有记,只有每篇的合计。
- 自托管要多少吞吐(估算):每天上限 20 篇约 36 万输入、25 万输出 token。摊到 24 小时,输出只要约 2.9 token/s;要在一小时内跑完,约 69 token/s。
- 单卡够不够(估算):吞吐的量级够;紧的是长度。抽取那次调用要放下整篇全文,而采集代码不截断全文。24 GiB 卡上 8B bf16 模型的 KV 最多约 5.8 万 token(vLLM 默认),SGLang 默认只有约 1.6 万;要按最长的论文设
max_model_len,并发也被它压住。 - 适合批处理:一天一批、没人等首 token。现在是串行,引擎每步只有一个请求,解码受带宽限制、用不上连续批处理。改成几篇并发,总吞吐上限近似按并发数涨,直到 KV 放不下。
- 实际吞吐待实测(C3-2)。模型能否写出合格的 JSON、翻译质量是否够,不在本卷,要靠评估。
它每天做什么
sequenceDiagram
participant C as 定时任务
participant P as 采集程序
participant H as HF Daily Papers
participant A as arXiv
participant M as 模型服务
C->>P: 跑前一天
P->>H: 取列表,入库
loop 每篇,按点赞数取前 N(串行)
P->>A: 取全文 HTML
P->>M: 全文 → 英文 JSON(不合格重试 1 次)
M-->>P: 回复 + usage
P->>M: 英文 JSON → 中文(不合格重试 1 次)
M-->>P: 回复 + usage
P->>P: 两种语言写库,累加 token
P->>P: 睡 3 秒
end
P->>P: 写一行运行记录(篇数、token、耗时)
入口是 papers-ingest 二进制,文件头注释写明「服务器 cron 每天跑一次」,不带日期时跑前一天(papers_ingest.rs#L2、#L44)。
串行的主循环
crates/modules/papers/src/ingest/mod.rs#L174
for (i, p) in picked.into_iter().enumerate() {
if i > 0 {
tokio::time::sleep(ARXIV_INTERVAL).await;
}
let id = p.arxiv_id.clone();
let mut usage = LlmUsage::default();
let outcome = generate_one(db, g, p, &mut usage).await;
picked是当天还没生成的论文,按点赞数降序取前daily_limit篇(#L166)。上限默认 20,PAPERS_DAILY_LIMIT可改(#L29)。- 每篇之间睡 3 秒,注释说这是 arXiv 的限速要求(#L23)。它约束的是取全文,不是调模型;现在两件事在同一个循环里,所以模型调用也跟着串行。
- 单篇失败只把那一篇标成
failed,不影响其他篇。
每篇两步
crates/modules/papers/src/ingest/mod.rs#L205
let (text, sections, has_html, version) = match fulltext::fetch(&g.http, &p.arxiv_id).await? {
Some((ft, version)) => (ft.text, ft.sections, true, version),
None => (format!("[§Abstract]\n{}", p.r#abstract), Vec::new(), false, None),
};
let (en, checked) =
generate::digest_en(g.llm.as_ref(), &p.title, &text, §ions, usage).await?;
let zh = generate::translate_zh(g.llm.as_ref(), &en, usage).await?;
- 抽取:系统提示要求只返回一个 JSON 对象,含一句话总结、问题、方法、3–6 条结果(带原文数字和节号)、局限、要点和思维导图(generate.rs#L21)。用户消息是标题加整篇全文。没有 HTML 版的论文只用摘要。全文抓取只解析 HTML,不设长度上限(fulltext.rs#L136)。
- 翻译:把英文 JSON 原样发过去,要求保持结构、键和数组长度,节号和数字不动(generate.rs#L35)。输入大约就是上一步的输出。
翻译依赖抽取的结果,同一篇里只能先后;不同篇之间没有依赖。
校验不过,带着前文重试一次
crates/modules/papers/src/ingest/generate.rs#L65
for attempt in 0..2 {
let reply = llm.complete(messages.clone()).await.map_err(|e| format!("模型调用失败:{e}"))?;
add(usage, reply.usage);
// ...
messages.push(LlmMessage::assistant(reply.text));
messages.push(LlmMessage::user(format!(
"Your JSON has these problems: {last_problems}. Return the corrected JSON object only."
)));
}
重试时把第一次的全部消息、模型的回复和问题描述一起再发一遍。对抽取来说,重试的输入是「整篇全文 + 上一次的回复 + 一句话」,比第一次还长。这对自托管有一个好处:重试请求的前缀和第一次完全相同,前缀缓存没被驱逐的话可以整段命中(前缀缓存)。抽取的系统提示每篇都一样,也会命中,但只有几百 token。命中率待实测(C3-3)。
请求长什么样
#[derive(Serialize)]
struct ChatRequest<'a> {
model: &'a str,
messages: &'a [WireMessage<'a>],
}
请求体只有模型名和消息,不传 max_tokens、温度、stream,也不要求结构化输出。JSON 格式完全靠提示词加事后校验和重试。几个直接后果:
- 输出长度的上限由服务端定。vLLM 不传
max_tokens时上限是「max_model_len− 输入长度」(api_utils.py#L169);SGLang 的聊天接口不传时max_new_tokens为空(protocol.py#L1150),调度器再按上下文和 KV 池截(scheduler.py#L2585)。 - 非流式:一次调用要等全部输出生成完才返回。单次调用的超时是 600 秒,注释说长论文抽取实测接近 30 秒(mod.rs#L24)。
- 服务端返回非 2xx 时,客户端把状态码和响应体前 500 字符变成错误(openai.rs#L201),那一篇被标成
failed,错误原文存进库。自托管时请求超长被拒,就会以这种形式出现。
模型由 OPENAI_LLM_BASE_URL、OPENAI_LLM_API_KEY、OPENAI_LLM_MODEL 三个环境变量指定,前两个缺一个就不生成(mod.rs#L40)。所以换成自托管不用改代码:基地址指向 vllm serve 或 SGLang 的服务,模型名改成服务端的模型名,密钥填一个非空值(服务端没设密钥时不校验,设了就要一致)。
负载有多大
每次运行结束写一行记录,含篇数、输入输出 token、耗时(mod.rs#L123)。token 数来自托管 API 返回的 usage,按每次调用累加。下面是线上两次采集的汇总,只用数字:
| 运行 | 生成篇数 | 输入 token | 输出 token | 耗时 | 每篇输入 | 每篇输出 | 每篇耗时 |
|---|---|---|---|---|---|---|---|
| 第 1 次 | 14 | 259,146 | 173,265 | 837.7 s | 18,510 | 12,376 | 59.8 s |
| 第 2 次 | 3 | 45,271 | 38,289 | 181.7 s | 15,090 | 12,763 | 60.6 s |
| 合计 | 17 | 304,417 | 211,554 | — | 17,907 | 12,444 | — |
两次都没有失败的篇。读这张表要注意四点:
- 只有每篇的合计。 每次调用的输入输出没有单独记,抽取那次多长、重试了几次都拆不出来。要得到长度分布,得先让调用方按调用记日志。
- 输出偏大。 两个 JSON 对象一共 1.2 万 token,比直觉多。
usage的输出数按注释包含推理 token(lib.rs#L156),重试也会叠加,哪个占多少拆不开。换成一个不做推理的 8B 模型,输出量会不同。 - token 按托管模型的分词器数。 换模型后同一篇全文的 token 数会变。
- 耗时包括抓全文和每篇 3 秒的等待。 第 1 次运行平均每秒 207 个输出 token,服务端单个请求的实际生成速度比这更高。
换成自托管:估算
以下都是估算,方法和输入沿用为什么服务大模型难与显存预算:Llama-3.1-8B 形状、bf16、24 GiB 卡;token 数直接用上表,不换算分词器。
吞吐需求:很小
| 每天篇数 | 输入 token | 输出 token | 24 小时平均输出 | 一小时跑完需要的输出 |
|---|---|---|---|---|
| 14(第 1 次的量) | 250,696 | 174,221 | 2.0 token/s | 48 token/s |
| 20(默认上限) | 358,138 | 248,887 | 2.9 token/s | 69 token/s |
每天几十万 token,摊开后每秒几个。预填充也不重:每天的输入约 36 万 token,按每 token 15.01 GFLOP 的线性层计算量,合计约 5.4 PFLOP;即便按一个假设的 100 TFLOP/s 有效算力,也只要约 54 秒。时间几乎全在解码上。
解码:串行时受带宽限制
解码每步要把 16.06 GB 权重读一遍,再读每个请求自己的 KV。下表按一个假设的带宽 1 TB/s 算下界,只看比例,不对应任何一张卡;每个调用的上下文按平均 1.5 万 token 假设(每篇输入 1.8 万分到至少两次调用,抽取那次占大头),这是假设,不是统计:
decode B=1 ctx=15000: KV=1.83 GiB, step>=18.0 ms, agg<=55 tok/s
decode B=2 ctx=15000: KV=3.66 GiB, step>=20.0 ms, agg<=100 tok/s
decode B=3 ctx=15000: KV=5.49 GiB, step>=22.0 ms, agg<=137 tok/s
decode B=14 ctx=15000: KV=25.63 GiB, step>=43.6 ms, agg<=321 tok/s
- 串行(B = 1):每秒不超过约 55 个 token。第 1 次运行的 17.3 万输出 token,光解码就至少约 52 分钟,托管 API 跑完整个流程用了 14 分钟。
- 3 篇并发:上限约 137 token/s,同样的量至少约 21 分钟。KV 约 5.5 GiB,在 vLLM 默认的 7.12 GiB 上界以内。
- 14 篇全并发:KV 要 25.6 GiB,单卡放不下。
所以单卡的吞吐够用,前提是并发;并发数被 KV 压在个位数。
长度:最紧的约束
抽取那次调用的长度是整篇全文加输出。采集代码不截断全文,统计里也没有单次调用的最大值,所以只能讲约束,讲不出「够不够」:
- vLLM:模型默认
max_model_len是 131072,满长一个请求要 16 GiB KV,24 GiB 卡放不下,按默认参数启动就会报To serve at least one request with the model's max seq len(常见报错)。要设--max-model-len,比如 32768:每个满长请求 4 GiB,最坏并发约 1.8;实际请求短于它时能多跑几个。超过它的论文,请求以This model's maximum context length is被拒,那一篇标成failed。 - SGLang:默认比例下 KV 池约 1.6 万 token,单请求的输入上限随之降到约 15,873(显存预算)。平均每篇输入就有 1.8 万,篇幅长的论文的抽取调用很可能以
exceeds the maximum allowed length被拒。要手设--mem-fraction-static或调小--chunked-prefill-size。
要决定 max_model_len 和并发数,得先有单次调用的长度分布。
为什么适合批处理
- 到达方式:一天一批,定时跑,没人在等。看的是「一批跑完要多久」,不是首 token 延迟和每 token 延迟。
- 现在用不上连续批处理:调用方串行,引擎任何时刻只有一个请求。连续批处理要的是同时有多个请求可以拼进一步;B = 1 时每步读一遍权重只为一个 token,正是解码访存受限的最坏情况。
- 能并发的地方:不同篇之间没有依赖。arXiv 的 3 秒限速只管取全文,可以先串行把全文都取下来,再以固定并发数(比如 2 到 3,按 KV 定)同时跑各篇的抽取和翻译。这是改法,框架里还没做。
- 长 prompt 不伤别人:交互服务怕长预填充挡住别人的解码(分块预填充),这里同一批的请求都属于同一个任务,只要总吞吐高,谁先谁后无所谓。
- 卡不必常驻:24 小时平均每秒几个 token,让一张卡全天开着服务这点负载很浪费。按批起服务、跑完关掉更合适;启动要加载权重、测显存、捕获 CUDA graph,这段时间也算进每天的成本,多长待实测。
不在本卷的事
自托管的 8B 模型能不能写出过得了校验的 JSON(节号必须出现在原文、数字必须逐字取自原文,check.rs#L19),中文翻译质量够不够,这些是模型质量问题,本卷只讲服务。重试率一高,token 量和耗时都会上去,所以质量最终也会影响上面的估算。这要靠评估卷,还没写。
待实测
- 同一个 8B 模型在 vLLM 0.32.0 与 SGLang 0.5.22 上,用本栏目的真实提示词跑一天的量:并发 1、2、3 时一批的总耗时、总吞吐、失败篇数(超长被拒、校验不过):待实测(C3-2)。
- 重试请求的前缀缓存命中:待实测(C3-3)。
- 前提:调用方先按调用记录输入输出长度,才能把真实的长度分布当负载。
小结
- 论文资讯栏目每天跑一次,按点赞数取前 20 篇,每篇「全文 → 英文 JSON → 中文」两步、各至多重试一次,全程串行;请求只带模型名和消息。
- 线上两次采集的汇总:每篇约 17,907 输入、12,444 输出 token,约 60 秒;只有每篇合计,没有单次调用的长度。
- 估算:每天 25 万输出 token 左右,24 小时平均不到 3 token/s,一小时跑完约 69 token/s。单卡 8B 的吞吐够用,前提是几篇并发;串行时解码上限约 55 token/s(假设 1 TB/s 带宽)。
- 最紧的是长度:全文不截断,vLLM 要按最长论文设
max_model_len,SGLang 默认 KV 池只有约 1.6 万 token。 - 一天一批、不等人,适合批处理:先串行取全文,再按 KV 允许的并发数跑生成。实际吞吐待实测(C3-2);模型质量靠评估。