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

上手 Meta Muse Ember:轻量模型进智能体循环

这篇能做出什么

先说结果。照着这篇敲完,你会得到一个能跑起来的最小系统,包含四样东西:

1. 统一的模型适配层。同一个 chat() 函数签名,传 "big" 走重模型,传 "ember" 走 Ember。切换只改一个环境变量,业务代码不动。

2. 一个工具路由器。把工具清单(JSON Schema)和当前这一步的任务描述交给 Ember,由它决定调哪个工具、传什么参数。模型只负责「选」,不负责「想」。

3. 两段式智能体循环。重模型当规划器(planner),把用户请求拆成几个明确动作;Ember 当执行器(executor),一步一个动作地调工具。执行段不烧重模型的 token。

4. 一张自己跑出来的对比表。同一批任务,分别在「全重模型」和「重模型规划 + Ember 执行」两种配置下跑,记录端到端延迟、输入输出 token,再乘你账号里的单价,得到单任务成本。

举个具体场景,方便你对齐预期:夜里告警响了,值班同学丢给智能体一句话——「checkout-api 的 P95 延迟刚才飙到 1.2 秒,帮我看看是不是下游超时,如果确认是下游问题就重启一下」。

这件事拆开来是三个动作:查指标、搜日志、重启服务。三个动作都不需要「思考」,需要的是准确地把参数填对、按顺序执行、把结果如实带回。这种段落就是典型的「高频执行段」,也正是把模型降档到 Ember 这类轻量执行模型最划算的地方。真正需要判断的只有「要不要重启」这一步——那一步留在规划器里。

贯穿全文的一个前提:Ember 通过 OpenAI 兼容的 Chat Completions 接口对外提供服务,这是目前多数模型服务商的默认形态。如果你拿到的官方 SDK 形状不同,改动集中在第 2 步构造 client 的那几行,后面的工具路由和循环逻辑完全不用动。base_url、模型标识(model name)、是否支持 tool use / function calling、上下文长度、计费口径,一律以官方文档当前版本为准,不要凭记忆或凭本文写死。

---

前置条件清单

动手前确认这几项,缺一项后面都会卡:

  • [ ] 一个能调通 Ember 的账号和 API Key,以及控制台里那个准确的模型标识字符串(model 参数用的那个,不是产品名)
  • [ ] 一个重模型的账号和 Key(用来做规划和兜底)
  • [ ] Python 3.9 以上
  • [ ] pip install openai python-dotenv(不锁版本,装当前版本即可)
  • [ ] 能访问外网的环境
  • [ ] 你自己的计费页:输入 token 单价、输出 token 单价。这两个数字本文不会替你猜,最后算成本时要用
  • [ ] 三个能真实调用的工具函数。本文用假的监控/日志/重启函数占位,形状对上了就能替换

---

第 1 步:先跑通一次最小调用

不要一上来就写循环。先用十行代码确认「Key 对、地址对、模型标识对」。新建 .env:

```bash

.env —— 所有值都从你自己的控制台复制,不要手打

BIG_API_KEY=sk-xxxx

BIG_BASE_URL=https://your-provider.example.com/v1

BIG_MODEL=your-heavy-model-id

EMBER_API_KEY=sk-yyyy

EMBER_BASE_URL=https://your-ember-endpoint.example.com/v1

EMBER_MODEL=your-ember-model-id

```

冒烟测试 smoke.py:

```python

smoke.py

import os

from dotenv import load_dotenv

from openai import OpenAI

load_dotenv()

client = OpenAI(

api_key=os.environ["EMBER_API_KEY"],

base_url=os.environ["EMBER_BASE_URL"],

)

resp = client.chat.completions.create(

model=os.environ["EMBER_MODEL"],

messages=[{"role": "user", "content": "只回复两个字:就绪"}],

temperature=0,

)

print(resp.choices[0].message.content)

print(resp.usage)

```

```bash

python smoke.py

```

看到「就绪」和一段 usage,说明链路通了。如果这一步就报 404,八成是 base_url 少了或多了 /v1;报 401 就是 Key 不对;报 model not found 就是模型标识抄错——这三种错误占了新手期的大部分时间,先在这一步排干净,别带进后面的循环里。

---

第 2 步:封一层模型适配层

后面的代码要频繁切换重模型和 Ember,把 client 和模型名收进一个字典,业务侧只认 tier 这个名字。

```python

llm.py

import os

import time

from dotenv import load_dotenv

from openai import OpenAI

load_dotenv()

_CLIENTS = {

"big": OpenAI(api_key=os.environ["BIG_API_KEY"], base_url=os.environ["BIG_BASE_URL"]),

"ember": OpenAI(api_key=os.environ["EMBER_API_KEY"], base_url=os.environ["EMBER_BASE_URL"]),

}

_MODELS = {

"big": os.environ["BIG_MODEL"],

"ember": os.environ["EMBER_MODEL"],

}

def chat(tier: str, messages: list, tools: list | None = None, **kwargs) -> dict:

"""统一调用入口。返回统一结构,方便后面统一埋点。"""

payload = {"model": _MODELS[tier], "messages": messages, **kwargs}

if tools:

payload["tools"] = tools

t0 = time.perf_counter()

resp = _CLIENTS[tier].chat.completions.create(**payload)

latency_ms = (time.perf_counter() - t0) * 1000

msg = resp.choices[0].message

usage = resp.usage

return {

"tier": tier,

"text": msg.content or "",

"tool_calls": [tc.model_dump() for tc in (msg.tool_calls or [])],

"prompt_tokens": getattr(usage, "prompt_tokens", 0) or 0,

"completion_tokens": getattr(usage, "completion_tokens", 0) or 0,

"latency_ms": round(latency_ms, 1),

}

```

注意 tool_calls 统一转成了普通 dict。不同 SDK 版本返回的是对象,直接丢进 messages 里再发回去容易序列化失败,转一次省很多事。

---

第 3 步:把工具清单交给 Ember

工具描述的质量,直接决定路由准不准。写工具 schema 有三条经验:description 写「什么时候该用它」,不是「它是什么」;参数 description 给例子;别把可选参数写得太随意,模型会瞎填。

```python

tools.py

import json

def query_metrics(service: str, window: str = "5m") -> dict:

"""占位实现。替换成你真实的监控查询。"""

return {"service": service, "window": window,

"cpu": 0.82, "error_rate": 0.031, "p95_ms": 1240}

def search_logs(service: str, keyword: str, limit: int = 20) -> dict:

return {"service": service, "keyword": keyword, "hits": [

{"ts": "2025-01-01T00:00:00Z", "level": "ERROR",

"msg": f"upstream timeout calling payment-gw ({keyword})"}

][:limit]}

def restart_service(service: str) -> dict:

return {"service": service, "action": "restart", "ok": True}

REGISTRY = {

"query_metrics": query_metrics,

"search_logs": search_logs,

"restart_service": restart_service,

}

TOOL_SCHEMAS = [

{

"type": "function",

"function": {

"name": "query_metrics",

"description": "查询某个服务最近一个时间窗口的 CPU、错误率、P95 延迟。当需要判断服务是否异常、异常到什么程度时调用。",

"parameters": {

"type": "object",

"properties": {

"service": {"type": "string", "description": "服务名,例如 checkout-api"},

"window": {"type": "string", "description": "时间窗口,例如 5m、1h。默认 5m"},

},

"required": ["service"],

},

},

},

{

"type": "function",

"function": {

"name": "search_logs",

"description": "在指定服务的日志里按关键词搜索。当需要确认错误的具体原因、找到上下游是哪个依赖出问题时调用。",

"parameters": {

"type": "object",

"properties": {

"service": {"type": "string", "description": "服务名,例如 checkout-api"},

"keyword": {"type": "string", "description": "搜索关键词,例如 timeout、OOM、payment-gw"},

"limit": {"type": "integer", "description": "返回条数上限,默认 20"},

},

"required": ["service", "keyword"],

},

},

},

{

"type": "function",

"function": {

"name": "restart_service",

"description": "重启一个服务实例。属于有副作用的操作,只有在已经确认服务本身异常、且用户明确要求时才调用。",

"parameters": {

"type": "object",

"properties": {

"service": {"type": "string", "description": "服务名,例如 checkout-api"},

},

"required": ["service"],

},

},

},

]

```

工具数量建议先控制在 10 个以内。工具越多,轻量执行模型选错的概率越高——这不是 Ember 特有的问题,是所有小参数模型的共性。超过 10 个就先按业务域分组,路由两次。

---

第 4 步:写执行循环

执行器的 system prompt 只有一件事要说清楚:你只做当前这一步。不要让它规划,不要让它总结整件事,那会诱导它编造没查过的结论。

```python

executor.py

import json

from llm import chat

from tools import REGISTRY, TOOL_SCHEMAS

DANGEROUS = {"restart_service"}

EXECUTOR_SYS = (

"你是一个执行器。只完成当前这一步:根据任务描述调用合适的工具,"

"拿到工具结果后用一句话陈述事实。"

"不要规划后续步骤,不要推测工具没有返回的数据,"

"如果缺少必要参数就不要调用工具,直接说明缺什么。"

)

def run_tool(name: str, args: dict) -> dict:

fn = REGISTRY.get(name)

if fn is None:

return {"error": f"unknown tool: {name}"}

try:

return fn(**args)

except Exception as e:

return {"error": f"{type(e).__name__}: {e}"}

def execute(task: str, tier: str = "ember", max_steps: int = 6,

allow_dangerous: bool = False) -> dict:

messages = [

{"role": "system", "content": EXECUTOR_SYS},

{"role": "user", "content": task},

]

trace = []

for step in range(max_steps):

out = chat(tier, messages, tools=TOOL_SCHEMAS, temperature=0)

trace.append(out)

if not out["tool_calls"]:

return {"answer": out["text"], "trace": trace, "steps": step + 1, "error": None}

messages.append({

"role": "assistant",

"content": out["text"] or None,

"tool_calls": out["tool_calls"],

})

for tc in out["tool_calls"]:

name = tc["function"]["name"]

raw = tc["function"]["arguments"]

args = json.loads(raw) if isinstance(raw, str) else (raw or {})

if name in DANGEROUS and not allow_dangerous:

result = {"error": "该工具有副作用,需要人工确认,已拒绝执行"}

else:

result = run_tool(name, args)

messages.append({

"role": "tool",

"tool_call_id": tc["id"],

"content": json.dumps(result, ensure_ascii=False),

})

return {"answer": None, "trace": trace, "steps": max_steps, "error": "step_limit"}

```

两个细节值得单独说:

tc["function"]["arguments"] 在有的实现里是 JSON 字符串,有的直接给 dict,所以这里做了 isinstance 判断。这行兼容代码建议永久保留,换供应商时会救你一次。

tool 消息必须带 tool_call_id,并且紧跟在带 tool_calls 的 assistant 消息之后。顺序错了或者 id 对不上,接口会直接报 400。这是自研循环里出现频率最高的报错。

---

第 5 步:接成两段式智能体循环

规划器用重模型,输出严格 JSON;执行器用 Ember,一步一调。

```python

agent.py

import json

from llm import chat

from executor import execute

PLANNER_SYS = """你是规划器。把用户请求拆成 1-4 个可独立执行的小步骤。

每个步骤必须是一个明确的动作,能被下面这些工具之一完成,或是一次纯文本总结。

可用工具:query_metrics、search_logs、restart_service。

只输出 JSON,不要 markdown 围栏,不要解释。格式:

{"steps": [{"id": 1, "action": "查询 checkout-api 近 5 分钟指标", "tool_hint": "query_metrics"}]}

有副作用的重启类动作,必须单独成一步,并在 action 里写明触发条件。"""

def plan(request: str) -> tuple[list, dict]:

out = chat("big", [

{"role": "system", "content": PLANNER_SYS},

{"role": "user", "content": request},

], temperature=0)

text = out["text"].strip()

if text.startswith("```"):

text = text.split("```")[1].removeprefix("json").strip()

return json.loads(text)["steps"], out

def run_agent(request: str, allow_dangerous: bool = False) -> dict:

steps, plan_out = plan(request)

results = []

for s in steps:

task = (

f"当前这一步:{s['action']}\n"

f"用户原始请求(仅作上下文):{request}\n"

f"请调用合适的工具完成当前这一步。"

)

r = execute(task, tier="ember", allow_dangerous=allow_dangerous)

if r["answer"] is None: # 执行器卡住或超步数,升级兜底

r = execute(task, tier="big", allow_dangerous=allow_dangerous)

results.append({"step": s, "result": r, "escalated": True})

else:

results.append({"step": s, "result": r, "escalated": False})

return {"plan": steps, "results": results, "planner": plan_out}

```

跑一下:

```python

main.py

import json

from agent import run_agent

out = run_agent(

"checkout-api 的 P95 刚才飙到 1.2 秒,帮我看看是不是下游超时,"

"如果确认是下游问题就重启一下这个服务。",

allow_dangerous=False, # 第一次跑先关掉副作用工具

)

print(json.dumps(out, ensure_ascii=False, indent=2))

```

第一次跑建议 allow_dangerous=False,让「重启」这一步被拦下来,你就能看到拒绝分支长什么样。确认链路没问题再打开。

---

第 6 步:埋点,把延迟和成本算出来

这一步是整篇的重点。降档能不能成立,取决于数据,不取决于感觉。

```python

bench.py

import time

from agent import run_agent

from executor import execute

from llm import chat

from dotenv import load_dotenv

import os

load_dotenv()

IN_PRICE = float(os.environ["EMBER_IN_PRICE"]) # 每百万输入 token 单价,从你的计费页抄

OUT_PRICE = float(os.environ["EMBER_OUT_PRICE"])

TASKS = [

"查一下 checkout-api 近 5 分钟的指标",

"在 checkout-api 日志里搜 timeout 相关的错误",

"查一下 payment-gw 的错误率",

……建议准备 15-30 条你真实会遇到的执行段任务

]

def cost(tier, p_tok, c_tok):

if tier == "big":

ip, op = float(os.environ["BIG_IN_PRICE"]), float(os.environ["BIG_OUT_PRICE"])

else:

ip, op = IN_PRICE, OUT_PRICE

return p_tok / 1_000_000 * ip + c_tok / 1_000_000 * op

def sum_tokens(trace):

return (sum(t["prompt_tokens"] for t in trace),

sum(t["completion_tokens"] for t in trace),

sum(t["latency_ms"] for t in trace))

for label, tier in [("全重模型", "big"), ("Ember 执行", "ember")]:

lat, p_tok, c_tok, spend = [], 0, 0, 0.0

for task in TASKS:

t0 = time.perf_counter()

r = execute(task, tier=tier)

lat.append((time.perf_counter() - t0) * 1000)

p, c, _ = sum_tokens(r["trace"])

p_tok += p

c_tok += c

spend += cost(tier, p, c)

lat.sort()

n = len(lat)

print(f"{label}: p50={lat[n//2]:.0f}ms p95={lat[int(n*0.95)-1]:.0f}ms "

f"tokens={p_tok}/{c_tok} 单任务成本≈{spend/n:.6f}")

```

输出会长这样(下面数字是格式示例,不是实测值,你必须自己跑):

```

全重模型: p50=3900ms p95=7200ms tokens=41000/6200 单任务成本≈0.0XXXXX

Ember 执行: p50=1150ms p95=2300ms tokens=12000/1800 单任务成本≈0.00XXX

```

真正要看的是三个比值:

  • 延迟比:p50 和 p95 都要看。执行段的收益主要来自输出短、模型小,通常 p95 改善比 p50 更明显。
  • 口径比:把总 token 数除以任务数,得到「单任务 token」。如果 Ember 那一栏的 token 反而更高,说明你的 system prompt 太长或者工具 schema 太啰嗦,先精简再谈降档。
  • 成本比:拿上面算出来的数除以全重模型那一栏。注意成本比不等于 token 比,因为两边的输入输出单价不同。单价永远以你自己的计费页为准。

还有一笔隐性账要记:如果 Ember 的失败率(answer is None 或升级兜底的比例)超过某个你能接受的水平,升级那部分等于双倍付费。所以 bench 里一定要统计 escalated 的条数,把它加到成本里。

---

常见坑与排错

1. tool_calls 发回去报 400

最常见。检查两点:tool_call_id 是否和 assistant 消息里的一致;tool 消息是否紧跟在对应的 assistant 消息后面。中间插了别的消息就会挂。

2. 参数解析炸在 json.loads

轻量模型偶尔会输出带尾逗号或者单引号的「近似 JSON」。加一层容错:解析失败就把原始字符串塞回 tool 消息,内容写 {"error": "参数不是合法 JSON,请重新生成"},让它自己改一次,比直接抛异常优雅得多。

3. 模型反复调同一个工具

通常是工具返回里没有它想要的信息。检查 query_metrics 的返回结构,是不是缺少它判断所需的字段。工具返回要给「结论性字段」(error_rate、p95_ms),不要只给一堆原始时间序列。

4. 上下文越来越长,延迟越来越高

执行循环里每次都要把完整历史发一遍。控制手段:max_steps 设小(4-6 步够用)、工具返回只保留关键字段、超步数直接升级而不是继续重试。

5. 有副作用的工具被误触发

DANGEROUS 白名单是硬性闸门。重启、删除、扩缩容、发消息这类动作,默认拒绝,走人工确认。不要指望 prompt 里写一句「谨慎操作」就能拦住。

6. 降档之后「变笨」了

先分清是模型问题还是任务切分问题。执行段的任务必须是动作明确、参数可枚举、结果可验证的。像「帮我评估一下这套架构要不要拆」这种,塞给执行器一定失败——它不是执行任务,是决策任务,应该留在规划器。

7. 超时和重试

对查询类工具可以自动重试一次;对重启类工具不要自动重试。非幂等操作的重复执行比失败本身更麻烦。

8. 不同供应商的 tool use 支持粒度不一样

有的支持并行 tool calls,有的只支持单个。如果你的 Ember 端点不支持 tools 参数,退路是让它输出结构化的 JSON({"tool": "...", "args": {...}}),你自己解析——路由逻辑一样成立,只是少了原生约束。

---

下一步建议

跑通之后,按这个顺序往下推:

第一步,扩任务集。 15 条任务跑出来的数字只能看趋势。攒到 50-100 条你真实遇到的执行段任务,覆盖查询类、搜索类、写入类,才谈得上「这条链路稳了」。

第二步,做灰度路由。 不要一次性全量切。按任务类型分流:查询类先切 Ember,写入类先留重模型。用 escalated 比例做监控指标,超过阈值自动回切。

第三步,把提示词当配置管。 执行器的 system prompt 和工具 schema 一起版本化。改 schema 就等于改模型行为,需要跟着跑一遍回归。

第四步,接可观测性。 把每次 chat() 的 tier / latency_ms / prompt_tokens / completion_tokens / tool_name 打到你的 trace 系统里。没有这份数据,后面所有「要不要再降一档」的讨论都是拍脑袋。

第五步,考虑缓存。 执行段里有大量重复查询(同一个服务、同一个时间窗口)。在 run_tool 外面套一层短 TTL 缓存,延迟和成本会同时下降——这一步的收益往往比换模型还大。

最后提醒一句:轻量执行模型的价值不在「便宜」,在「够用且快」。什么时候该降档、降到什么程度,答案永远在你自己跑出来的那张表里,不在任何人的推荐里。所有涉及版本、接口形态、计价的地方,以官方文档当前版本为准。

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