这篇教程带你从零做出一件事:一台不联网的机器上,跑着一个 HTTP 接口,你往里塞一段中文邮件、会议记录或新闻稿,几百毫秒内它就吐回一句话摘要加几条要点。
成品长这样:
```bash
curl -s http://127.0.0.1:8000/summarize \
-H 'Content-Type: application/json' \
-d '{"text":"(这里贴你的长文)","max_points":3}'
```
```json
{
"summary": "客户要求把交付时间从月底推迟到下月中旬,原因是上游芯片缺货。",
"points": [
"交付时间拟从本月底顺延至下月中旬",
"原因是上游芯片供应紧张",
"对方希望本周内给出口头确认"
],
"latency_ms": 1180,
"chunks": 2,
"cached": false
}
```
延迟目标我们自己定:短文本(千字以内)首字 300ms 级、整段 2 秒内出完;长文走分块合并,控制在 5 秒内。这些是预算目标,不是承诺,实际数字取决于你的机器。
关于模型名字:本文说的「nano 档」指的是参数量大约 1B~4B、量化后权重 1~3GB 的那一类超小模型。GPT-5.4 nano 属于这一档——但它是否开源、权重从哪下载、许可证怎么算,一律以官方页面为准,我不在这里替你确认。所以下面的命令全部用 <model> 占位,你换成手上真正能拿到的那个权重名就行,其余步骤完全一致。
---
前置条件清单
动手前对照一遍,缺哪项先补哪项:
- 硬件:内存 8GB 起步,16GB 更舒服。有 Apple Silicon、独显或核显会明显更快,但没有 GPU 用纯 CPU 也能跑完这篇教程,只是延迟高一些。
- 系统:macOS、Linux 或 Windows + WSL2。原生 Windows 跑 Ollama 没问题,llama.cpp 建议走 WSL2 省心。
- 软件:Python 3.10+、
pip、git、curl、CMake(要自己编译 llama.cpp 时才需要)。 - 模型权重:GGUF 格式,建议先用
Q4_K_M这一档量化。文件名、下载方式以模型官方页面为准。 - 端口:
11434(Ollama 默认)、8080(llama.cpp server 默认)、8000(我们自己的 API)。三个别打架。
---
第 1 步:用 Ollama 先把链路跑通
先别追求极限性能。第一步只求「能出结果」,Ollama 是最省事的入口。
安装(安装方式以官方页面为准):
```bash
macOS / Linux
curl -fsSL https://ollama.com/install.sh | sh
ollama --version
```
拉模型并确认它在本地:
```bash
ollama pull <model>
ollama list
```
ollama list 里能看到模型名和体积,记住这个名字,下一步要用。
跑一句试试:
```bash
ollama run <model> "用一句话概括:今天下午三点开周会,讨论下季度预算。"
```
能出话就说明环境通了。这时候先别急着写服务,我们做一个「摘要专用」的模型别名,这一步收益很大。
---
第 2 步:写一个只干摘要的 Modelfile
裸模型什么都聊,你得在每次请求里塞长提示词,既费 token 又增加首字延迟。Ollama 支持把系统提示和推理参数固化进一个派生模型,建一个 Modelfile:
```dockerfile
Modelfile
FROM <model>
摘要要稳,不要发挥
PARAMETER temperature 0.2
PARAMETER top_p 0.9
PARAMETER repeat_penalty 1.05
上下文窗口:小模型别开太大,开大反而慢、也更吃内存
PARAMETER num_ctx 4096
输出上限:摘要本来就不长,卡死上限能防它啰嗦
PARAMETER num_predict 320
SYSTEM """
你是一个摘要引擎。你只依据用户提供的原文输出,不得引入原文之外的事实。
严格按以下格式输出,不要加任何额外说明:
一句话摘要:<不超过 60 字>
要点:
- <要点 1>
- <要点 2>
- <要点 3>
如果原文信息不足以支撑某条要点,就少写一条,绝不编造。
"""
```
构建并验证:
```bash
ollama create nano-sum -f Modelfile
ollama run nano-sum "(贴一段 500 字的邮件)"
```
现在你有了一个叫 nano-sum 的模型,任何客户端只要写这个模型名,就自动带上了摘要人设。参数含义以 Ollama 官方文档为准,不同版本可能有新增项。
---
第 3 步:测出你的基线延迟
写服务之前先量一次,不然你不知道后面优化了多少。直接用 HTTP 接口:
```bash
time curl -s http://localhost:11434/api/generate \
-H 'Content-Type: application/json' \
-d '{
"model": "nano-sum",
"prompt": "(贴一段测试文本)",
"stream": false,
"keep_alive": "30m"
}' | python3 -m json.tool
```
返回体里通常会有 eval_count(生成 token 数)和 eval_duration(纳秒),两者一除就是生成速度(tokens/s)。字段名以 Ollama 官方文档为准,不同版本有过调整。
第一次请求会明显偏慢,因为模型要从磁盘加载进内存。这就是接下来要处理的核心问题。
---
第 4 步:让模型常驻,干掉冷启动
冷启动是低延迟的头号杀手。两种办法,任选:
办法一:环境变量常驻。 给 Ollama 服务进程设置 keep-alive 时长(OLLAMA_KEEP_ALIVE),设成 -1 表示永久常驻,前提是你的内存扛得住。具体变量名和取值以官方文档为准。
办法二:请求级控制。 就是在上面 curl 里用的 "keep_alive": "30m"。服务启动时先发一次空跑请求预热,之后 30 分钟内的请求都命中已加载的模型。
办法三(推荐):在 API 服务启动时预热。 我们待会儿在 FastAPI 的 startup 钩子里发一条最短的请求,把模型顶进内存。预热请求不要用真正的长文本,用 "你好" 这种两三个 token 的就行。
---
第 5 步:换 llama.cpp,把参数攥在自己手里
Ollama 底层也是 llama.cpp,但它帮你做了很多默认决策。当你需要精细控制线程数、内存锁定、批大小的时候,直接上 llama.cpp 更顺手。
编译(macOS 开 Metal,Linux + NVIDIA 开 CUDA,纯 CPU 就不加后端开关):
```bash
git clone https://github.com/ggml-org/llama.cpp
cd llama.cpp
macOS(Metal)
cmake -B build -DGGML_METAL=ON
Linux + NVIDIA(CUDA)
cmake -B build -DGGML_CUDA=ON
cmake --build build --config Release -j
```
仓库地址和 CMake 选项名以项目官方文档为准,这个项目变动比较勤。
关于量化。 如果你拿到的是 Hugging Face 格式(safetensors),要先转 GGUF 再量化。转换脚本的文件名在不同版本里改过,以官方文档为准:
```bash
转成 f16 GGUF
python convert_hf_to_gguf.py ./你的模型目录 \
--outfile model-f16.gguf --outtype f16
量化到 Q4_K_M
./build/bin/llama-quantize model-f16.gguf model-q4_k_m.gguf Q4_K_M
```
本地摘要这种任务,Q4_K_M 是甜点档;嫌慢就退到 Q4_0,嫌质量差就往 Q5_K_M 走一档。量化档位名称以官方文档为准。
启动服务:
```bash
./build/bin/llama-server \
-m model-q4_k_m.gguf \
--host 127.0.0.1 --port 8080 \
-c 4096 \
-n 320 \
-t 6 \
--mlock
```
几个关键点:
-c 4096:上下文窗口,和 Modelfile 里的num_ctx对齐。-n 320:单次最多生成多少 token。-t 6:线程数,填物理核心数,不要填逻辑核心数。超线程开满往往更慢。--mlock:把模型锁在内存里不被换出,机器内存够就加上。- 有 GPU 时加
-ngl 99把层全部卸载到显存;具体可卸载层数以你的显存为准。
它同时提供了 OpenAI 兼容接口 /v1/chat/completions,这点很重要——下面写 API 时,Ollama 和 llama.cpp 可以共用一套调用代码,只换 base_url。
---
第 6 步:写统一摘要 API
新建目录,装依赖:
```bash
mkdir summary-api && cd summary-api
python3 -m venv .venv && source .venv/bin/activate
pip install fastapi uvicorn httpx pydantic
```
写 app.py:
```python
app.py
import os
import time
import hashlib
from typing import List
import httpx
from fastapi import FastAPI, HTTPException
from pydantic import BaseModel
---------- 配置:换后端只改这一个环境变量 ----------
BACKEND = os.getenv("SUMMARY_BACKEND", "ollama") # ollama | llamacpp
MODEL = os.getenv("SUMMARY_MODEL", "nano-sum")
BASE_URL = (
os.getenv("OLLAMA_BASE", "http://127.0.0.1:11434/v1")
if BACKEND == "ollama"
else os.getenv("LLAMACPP_BASE", "http://127.0.0.1:8080/v1")
)
SYSTEM_PROMPT = """你是一个摘要引擎。你只依据用户提供的原文输出,不得引入原文之外的事实。
严格按以下格式输出,不要加额外说明:
一句话摘要:<不超过 60 字>
要点:
- <要点 1>
- <要点 2>
- <要点 3>
信息不足以支撑某条要点时就少写一条,绝不编造。"""
单实例模型在 CPU 上并行反而更慢,用信号量串行化
import asyncio
SEM = asyncio.Semaphore(int(os.getenv("SUMMARY_CONCURRENCY", "1")))
client = httpx.AsyncClient(
base_url=BASE_URL,
timeout=httpx.Timeout(120.0, connect=5.0),
headers={"Authorization": f"Bearer {os.getenv('SUMMARY_API_KEY', 'none')}"},
)
app = FastAPI(title="Local Summary API")
CACHE: dict = {}
class SummarizeRequest(BaseModel):
text: str
max_points: int = 3
class SummarizeResponse(BaseModel):
summary: str
points: List[str]
latency_ms: int
chunks: int
cached: bool = False
def split_text(text: str, chunk_size: int = 1200, overlap: int = 100) -> List[str]:
"""按段落切块,单段过长再硬切。chunk_size 要明显小于上下文窗口。"""
text = text.strip()
if not text:
return []
paragraphs = [p.strip() for p in text.split("\n") if p.strip()]
chunks, buf = [], ""
for p in paragraphs:
if len(buf) + len(p) + 1 <= chunk_size:
buf = f"{buf}\n{p}" if buf else p
else:
if buf:
chunks.append(buf)
硬切超长段落,保留一点重叠避免语义被切断
while len(p) > chunk_size:
chunks.append(p[:chunk_size])
p = p[chunk_size - overlap:]
buf = p
if buf:
chunks.append(buf)
return chunks
async def chat(user_prompt: str, max_tokens: int = 320, temperature: float = 0.2) -> str:
"""Ollama 和 llama.cpp 都提供 OpenAI 兼容的 /chat/completions。"""
payload = {
"model": MODEL,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": user_prompt},
],
"temperature": temperature,
"max_tokens": max_tokens,
"stream": False,
}
async with SEM:
r = await client.post("/chat/completions", json=payload)
if r.status_code >= 400:
raise HTTPException(502, f"后端返回 {r.status_code}: {r.text[:300]}")
return r.json()["choices"][0]["message"]["content"].strip()
def parse_output(raw: str):
"""把模型输出拆成一句话摘要 + 要点列表。格式不对时降级处理。"""
summary, points = "", []
for line in raw.splitlines():
line = line.strip()
if not line:
continue
if line.startswith("一句话摘要"):
summary = line.split(":", 1)[-1].split(":", 1)[-1].strip()
elif line.startswith("-") or line.startswith("*"):
points.append(line.lstrip("-* ").strip())
if not summary:
降级:整段当摘要,保证接口永远有输出
summary = raw.splitlines()[0][:200] if raw else ""
return summary, points
async def summarize_long(text: str, max_points: int):
chunks = split_text(text)
if not chunks:
raise HTTPException(400, "text 不能为空")
if len(chunks) == 1:
raw = await chat(f"请摘要以下内容,最多 {max_points} 条要点:\n\n{chunks[0]}")
return raw, 1
map:每块先各自压一遍
partials = []
for c in chunks:
partials.append(await chat(f"用不超过 80 字概括这段的关键事实:\n\n{c}", max_tokens=160))
reduce:合并去重
merged = "\n".join(f"- {p}" for p in partials)
raw = await chat(
f"下面是同一篇文档各段落的概括,请合并去重,"
f"输出一句话摘要和最多 {max_points} 条要点:\n\n{merged}",
max_tokens=320,
)
return raw, len(chunks)
@app.post("/summarize", response_model=SummarizeResponse)
async def summarize(req: SummarizeRequest):
key = hashlib.sha256(f"{req.max_points}|{req.text}".encode()).hexdigest()
if key in CACHE:
return SummarizeResponse({CACHE[key], "cached": True})
t0 = time.perf_counter()
raw, n_chunks = await summarize_long(req.text, req.max_points)
elapsed = int((time.perf_counter() - t0) * 1000)
summary, points = parse_output(raw)
result = {
"summary": summary,
"points": points[: req.max_points],
"latency_ms": elapsed,
"chunks": n_chunks,
}
CACHE[key] = result
return SummarizeResponse(**result)
@app.get("/healthz")
async def healthz():
"""探活 + 预热。启动后立刻打一次,把模型顶进内存。"""
try:
async with SEM:
路径以各后端官方文档为准
await client.get("/models")
return {"backend": BACKEND, "model": MODEL, "ready": True}
except Exception as e:
return {"backend": BACKEND, "model": MODEL, "ready": False, "error": str(e)}
@app.on_event("startup")
async def warmup():
try:
await chat("你好", max_tokens=8)
print(f"[warmup] {BACKEND} / {MODEL} 已加载")
except Exception as e:
print(f"[warmup] 预热失败(不影响启动): {e}")
```
启动:
```bash
单 worker,别开多进程,模型只有一份
uvicorn app:app --host 127.0.0.1 --port 8000 --workers 1
```
换后端就一行:
```bash
SUMMARY_BACKEND=llamacpp SUMMARY_MODEL=model-q4_k_m.gguf uvicorn app:app --port 8000
```
注意 llama.cpp server 的 model 字段有时不校验具体值,实际加载哪个权重取决于启动时 -m 指向谁;以官方文档为准。
---
第 7 步:验收一下
准备一份真实文本(比如一篇 3000 字的工作报告)存成 article.txt,用 Python 发请求,避免 curl 转义地狱:
```python
bench.py
import asyncio, time, statistics, httpx
URL = "http://127.0.0.1:8000/summarize"
TEXT = open("article.txt", encoding="utf-8").read()
async def one(client, i):
t0 = time.perf_counter()
r = await client.post(URL, json={"text": TEXT, "max_points": 3})
r.raise_for_status()
return (time.perf_counter() - t0) * 1000, r.json()
async def main():
async with httpx.AsyncClient(timeout=120) as c:
await c.post(URL, json={"text": "预热", "max_points": 1}) # 丢掉冷启动
lat = []
for i in range(5):
ms, data = await one(c, i)
lat.append(ms)
print(f"第{i+1}次 {ms:.0f}ms chunks={data['chunks']}")
print(f"中位数 {statistics.median(lat):.0f}ms 最大 {max(lat):.0f}ms")
asyncio.run(main())
```
第一次跑出来的数字,就是你的真实基线。如果中位数超过你定的预算,回到第 4 步查常驻,第 5 步查线程数和量化档位。
---
常见坑与排错
1. model not found。 先 ollama list 核对名字,注意 tag 后缀。名称、下载方式以模型官方页面为准。
2. 第一次请求慢、后面快。 典型的冷启动。用 keep_alive 让模型常驻;Ollama 服务重启后缓存会丢,所以 API 层要带预热。
3. 输出被截断在半句话。 要么 num_predict / max_tokens 太小,要么 num_ctx 装不下「系统提示 + 正文 + 已生成」。长文务必走分块,chunk_size 留足余量。
4. 报 context length exceeded。 同上。别指望调大窗口解决一切——窗口开大,小模型反而更容易在中间部分「走神」。
5. 摘要里出现了原文没有的内容。 小模型幻觉最防不胜防。三招:温度压到 0.2 以下;系统提示里写死「找不到就少写一条」;后处理阶段做一次粗校验,把明显不在原文里的数字或专有名词标出来人工复核。
6. CPU 跑满但吞吐上不去。 线程数填成逻辑核数了。改成物理核数再试。另外确认没有别的进程在抢内存——模型被换到 swap 上,速度会掉一个数量级。
7. 内存不足直接 OOM。 降量化档位(Q4_K_M → Q4_0),或者换更小的模型档。macOS 上看一眼「内存压力」是不是变黄变红了。
8. address already in use。 Ollama、llama.cpp、FastAPI 三者端口冲突。用 lsof -i :端口 找出来改掉。
9. curl 发中文 JSON 报解析错误。 引号和换行转义的问题。用 -d @req.json 从文件读,或者干脆用 Python 客户端。
10. 并发一高延迟爆炸。 单实例 llama.cpp 在 CPU 上并行请求会互相拖慢。我们的 Semaphore(1) 就是干这个的。有 GPU 时才考虑开 --parallel 做连续批处理,参数名以官方文档为准。
---
下一步建议
跑通之后,按收益从高到低排:
加流式输出。 现在是一次性返回,用户干等 2 秒。改成 SSE 逐字推,感知延迟立刻降到 300ms 级——人看到字在动就不觉得慢。llama.cpp 和 Ollama 都支持 stream: true。
上 GPU 或换更激进的量化。 有显卡就把 -ngl 99 打开,或者试 Q4_0。这一步通常能砍掉一半以上的延迟。
做真正意义上的缓存。 现在只按文本哈希做精确缓存,改一个字就失效。可以加一层语义缓存:用 embedding 模型把文本向量化,相似度超阈值就返回旧结果。对「每天重复摘要同一批模板邮件」这种场景特别划算。
建一个自己的评测集。 挑 30 篇你业务里的真实文档,人工写好标准答案,每次换模型或换量化档位都跑一遍。不然你只能凭感觉判断「是不是变笨了」,这在小模型上特别容易误判。
考虑双档路由。 短文本、格式规整的直接给 nano 档;长文或识别到复杂度高的,路由给本地稍大的模型兜底。用小模型做第一道闸门,是端侧落地最实用的架构。
接进日常工作流。 邮件客户端插件、会议纪要自动归档、日报生成——这套 API 的价值不在技术本身,在于把它挂到你每天真的会用的入口上。
最后提醒一句:模型权重能不能商用、下载地址、量化档位命名、各后端的参数名,这些都以各自的官方页面为准,版本迭代很快,动手前扫一眼文档能省不少时间。
