第 3 课

结构化输出、工具调用、语义搜索、路由模式与智能体

用 JSON Schema 约束本地模型抽取发票字段,写两轮工具调用让模型查天气,用 EmbeddingGemma 2 起向量服务做中文语义搜索,用不带模型的 llama serve 路由模式按需加载多个模型,最后按官方文档把本地模型接到 Codex CLI、ChatGPT 桌面版和 Pi。

图文25 分钟

课程目录第 3 / 3 课

学习位置仅保存在当前浏览器,有效期 180 天。

01 / 图文教材

图文教材

本课依据:Llama 官方文档「API server」https://llama.app/docs/api 、「Running a server」https://llama.app/docs/serve 、「Using Llama App with Agents」https://llama.app/docs/agents ,以及官方模型页 https://llama.app/models (2026-10-27 核对,llama build b11852)。

这一课做完你会得到什么

让本地模型按 JSON Schema 输出可直接解析的数据,让它调用你写的 Python 函数,用本地向量模型做一个中文语义搜索,用路由模式在一个端口上同时提供多个模型,最后了解怎么把本地模型接到 Codex CLI 和 Pi 这类编程智能体上。

先按第 2 课的方式启动聊天模型(关闭思考,别名 qwen3.5-0.8b):

llama serve -hf ggml-org/Qwen3.5-0.8B-GGUF:Q8_0 \
  -c 8192 -a qwen3.5-0.8b -rea off

第 1 步:结构化输出,抽取发票信息

response_format 里给出 JSON Schema,服务端会用语法约束生成过程,输出一定符合这个结构。新建 extract.py:

import json
from openai import OpenAI

client = OpenAI(base_url="http://localhost:9931/v1", api_key="x")

schema = {
    "type": "object",
    "properties": {
        "name": {"type": "string"},
        "date": {"type": "string",
                 "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}$"},
        "amount": {"type": "number"},
        "currency": {"type": "string", "enum": ["CNY", "USD"]},
    },
    "required": ["name", "date", "amount", "currency"],
}

text = ("发票:杭州云栖科技有限公司,"
        "开票日期 2026年10月20日,金额 1280.50 元。")

resp = client.chat.completions.create(
    model="qwen3.5-0.8b",
    messages=[{"role": "user", "content": "从下面文字抽取信息:\n" + text}],
    response_format={
        "type": "json_schema",
        "json_schema": {"name": "invoice", "schema": schema},
    },
    temperature=0,
)
data = json.loads(resp.choices[0].message.content)
print(json.dumps(data, ensure_ascii=False, indent=2))
uv run --with openai python extract.py

对照结果(2026-10-27 实测):

{
  "name": "杭州云栖科技有限公司",
  "date": "2026-10-20",
  "amount": 1280.5,
  "currency": "CNY"
}

原文写的是「2026年10月20日」,输出被约束成了 2026-10-20;金额是数字,币种只能是枚举里的值。

实测踩到的两个坑:

  • 日期正则要写 [0-9],不要写 \d。用 \d 时服务端日志提示不支持,这个字段会退化成任意字符串。
  • 官方 API 文档的示例把 schema 直接放在 response_format 下面。在 b11852 上用这种写法实测时输出没有被约束(模型返回了中文键名);按 OpenAI 标准写法放进 json_schema.schema 才生效。上面的代码用的就是标准写法。

只要合法 JSON、不限结构时,用 response_format={"type": "json_object"}。

第 2 步:工具调用,让模型查天气

用 OpenAI 的 tools 参数声明函数,模型决定要不要调、传什么参数;你的代码执行函数,把结果以 role: tool 消息发回去,模型再组织最终回答。新建 tools_demo.py:

import json
from openai import OpenAI

client = OpenAI(base_url="http://localhost:9931/v1", api_key="x")

def get_weather(city: str) -> dict:
    # 演示用的假数据,真实项目换成你的接口
    return {"city": city, "temp_c": 18, "sky": "多云"}

tools = [{
    "type": "function",
    "function": {
        "name": "get_weather",
        "description": "查询某个城市当前天气",
        "parameters": {
            "type": "object",
            "properties": {"city": {"type": "string"}},
            "required": ["city"],
        },
    },
}]

messages = [{"role": "user", "content": "杭州现在天气怎么样?"}]
r1 = client.chat.completions.create(
    model="qwen3.5-0.8b", messages=messages, tools=tools,
)
call = r1.choices[0].message.tool_calls[0]
args = json.loads(call.function.arguments)
print("模型要调用:", call.function.name, args)

result = get_weather(**args)
messages.append(r1.choices[0].message)
messages.append({
    "role": "tool",
    "tool_call_id": call.id,
    "content": json.dumps(result, ensure_ascii=False),
})
r2 = client.chat.completions.create(
    model="qwen3.5-0.8b", messages=messages, tools=tools,
)
print("最终回答:", r2.choices[0].message.content)
uv run --with openai python tools_demo.py

对照结果(2026-10-27 实测,节选):

模型要调用: get_weather {'city': '杭州'}
最终回答: 杭州目前(根据查询结果)天气情况如下:
*   **温度**:18°C
*   **天气状况**:多云

官方说明:针对工具调用训练过的模型会用它自己的原生格式,其他模型走通用兜底格式。真实项目里要处理模型不调用工具的情况,即 tool_calls 为空时直接用 content。

第 3 步:本地向量模型做语义搜索

另开一个终端,在 9932 端口起一个向量(embedding)服务。--embedding 让服务提供 /v1/embeddings:

llama serve -hf ggml-org/embeddinggemma-2-GGUF \
  --embedding --port 9932

这里用的是 llama.app 模型页里的 EmbeddingGemma 2(Apache 2.0),没写量化档位时这个仓库自动选了 Q8_0。官方文档示例用的是 unsloth/embeddinggemma-300m-GGUF,也可以。

新建 search.py,把三条规定和一个问题都转成向量,按余弦相似度排序:

import math
from openai import OpenAI

client = OpenAI(base_url="http://localhost:9932/v1", api_key="x")

docs = [
    "报销需在费用发生后 30 天内提交发票。",
    "年假按工龄计算,满一年 5 天,满十年 10 天。",
    "办公室 Wi-Fi 密码每季度更换一次。",
]
query = "出差的票据最晚多久要交?"

def embed(texts):
    r = client.embeddings.create(model="emb", input=texts)
    return [d.embedding for d in r.data]

def cos(a, b):
    dot = sum(x * y for x, y in zip(a, b))
    return dot / (math.hypot(*a) * math.hypot(*b))

q, *ds = embed([query] + docs)
scores = [(cos(q, d), doc) for d, doc in zip(ds, docs)]
for score, doc in sorted(scores, reverse=True):
    print(f"{score:.3f}  {doc}")
uv run --with openai python search.py

对照结果(2026-10-27 实测):

0.802  报销需在费用发生后 30 天内提交发票。
0.709  年假按工龄计算,满一年 5 天,满十年 10 天。
0.695  办公室 Wi-Fi 密码每季度更换一次。

问题里没有「报销」「发票」这两个词,但意思最接近的那条排在第一。每条向量是 768 维。文档多了以后,把向量存进向量数据库,再把排名靠前的几条交给第 2 课的聊天模型回答,就是一个本地 RAG。官方还支持 --rerank 重排序模型(/v1/rerank 接口),本课未实测。

第 4 步:路由模式,一个端口多个模型

先停掉前面两个服务。不带模型启动 llama serve,就进入路由模式:服务列出缓存里所有模型,请求里写哪个模型名就按需加载哪个。

llama serve -cl
llama serve -rea off

对照结果(2026-10-27 实测,-cl 的输出):

number of models in cache: 2
   1. ggml-org/embeddinggemma-2-GGUF:Q8_0
   2. ggml-org/Qwen3.5-0.8B-GGUF:Q8_0

路由模式下请求必须写 model,名字用 /v1/models 列出的完整名:

curl -s http://localhost:9931/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"model": "ggml-org/Qwen3.5-0.8B-GGUF:Q8_0",
       "messages": [{"role": "user",
                     "content": "用 5 个字以内说你好"}],
       "max_tokens": 20}'

对照结果(2026-10-27 实测,第一次请求含加载约 5 秒;只摘 model 和回答):

ggml-org/Qwen3.5-0.8B-GGUF:Q8_0 | 你好呀!

请求前后各查一次 /v1/models,模型状态从 unloaded 变成了 loaded:

ggml-org/Qwen3.5-0.8B-GGUF:Q8_0 loaded
ggml-org/embeddinggemma-2-GGUF:Q8_0 unloaded

启动时加的参数(这里是 -rea off)会带给每个按需加载的模型。除了缓存,还可以用 --models-dir 指定一个放 GGUF 文件的文件夹,或用 --models-preset 指定预设文件。

第 5 步:接到编程智能体

本节按官方 Agents 文档整理,未在云主机上实测:0.8B 模型不适合驱动编程智能体。官方建议上下文至少 32k token,并从 https://llama.app/models 里选标注 agentic coding 的模型(例如页面上写明 local agentic coding 的 GLM 4.7 Flash,标注需要 32 GB 以上内存)。

先用足够大的上下文起服务,例如:

llama serve -hf <模型仓库:量化> -c 32768

Codex CLI 和 ChatGPT 桌面版:安装 Codex CLI 后,在 ~/.codex/config.toml 里加入官方给出的配置,model 换成你实际服务的模型 id:

npm install -g @openai/codex
model = "ggml-org/Qwen3.5-35B-A3B-GGUF" # id of the model you serve
model_provider = "llamacpp"

[model_providers.llamacpp]
name = "llama.cpp"
base_url = "http://127.0.0.1:9931/v1"
wire_api = "responses"
requires_openai_auth = false
supports_websockets = false

然后在项目文件夹里运行 codex。ChatGPT 桌面版改完配置后退出重开即可。这里用的是 /v1/responses 接口(Responses API 格式),llama.cpp 在内部把它转换成聊天补全。

Pi:官方安装命令和接入步骤:

curl -fsSL https://pi.dev/install.sh | sh
cd /path/to/project
pi

在 Pi 里输入 /login llama.cpp,服务地址填 http://127.0.0.1:9931,再用 /model 选模型。

其他智能体:任何支持 OpenAI 兼容接口的工具,把 Base URL 设成 http://127.0.0.1:9931/v1 就能用。

小结

  • 结构化输出用 response_format.json_schema.schema,正则写 [0-9];
  • 工具调用是两轮请求:模型给出 tool_calls,你执行后用 role: tool 回传;
  • --embedding 起向量服务,配合余弦相似度就能做中文语义搜索;
  • 不带模型的 llama serve 是路由模式,请求里写哪个模型就按需加载哪个;
  • 接智能体要用大模型、32k 以上上下文,Base URL 都是 http://127.0.0.1:9931/v1。