跳到主内容
快讯直播
AI智模界
教程

用 nano 档小模型,在本地搭一个低延迟摘要 API

这篇教程带你从零做出一件事:一台不联网的机器上,跑着一个 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+、pipgitcurl、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 foundollama 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_MQ4_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 的价值不在技术本身,在于把它挂到你每天真的会用的入口上。

最后提醒一句:模型权重能不能商用、下载地址、量化档位命名、各后端的参数名,这些都以各自的官方页面为准,版本迭代很快,动手前扫一眼文档能省不少时间。

AI 生成本文由 AI 基于公开信息自动生成,仅供参考。