Tool 是 LLM 视角的「函数」。本章覆盖 Tool behaviour、Sandbox 代理模式、参数 schema、错误处理约定,以及 3 个完整 Tool 范例。
Tool behaviour
实现 CMDC.Tool 4 个 callback:
defmodule MyApp.MyTool do
@behaviour CMDC.Tool
@impl true
def name, do: "my_tool"
@impl true
def description, do: "做点什么的工具"
@impl true
def parameters do
%{
"type" => "object",
"properties" => %{
"query" => %{"type" => "string", "description" => "查询字符串"},
"limit" => %{"type" => "integer", "default" => 10}
},
"required" => ["query"]
}
end
@impl true
def execute(args, ctx) do
query = Map.fetch!(args, "query")
limit = Map.get(args, "limit", 10)
case do_search(query, limit) do
{:ok, results} -> {:ok, format(results)}
{:error, reason} -> {:error, "search failed: #{reason}"}
end
end
end挂载:
{:ok, session} = CMDC.create_agent(
model: "...",
tools: [MyApp.MyTool]
)返回约定
execute/2 必须返回三种之一:
| 返回 | 含义 | 注入到对话 |
|---|---|---|
{:ok, text} | 成功,text 给 LLM | tool_result 消息(is_error: false) |
{:error, text} | 失败,text 给 LLM 自我纠正 | tool_result 消息(is_error: true) |
{:effect, term} | 仅副作用,无文本回写 | 不注入对话(如 WriteTodos 改 ctx.todos) |
惯例:
- text 限制在 2-4KB;超大结果用
LargeResultOffloadPlugin 自动落盘 - error text 写人类可读的失败原因,让 LLM 能自己修
- 不要在 execute 里 raise——除非你确实想让 ToolRunner 回退到 retry 机制
(详见
Options.tool_max_retries)
ctx 是什么
ctx :: CMDC.Context.t():
%CMDC.Context{
session_id: "ar-123",
working_dir: "/tmp/work",
model: "anthropic:claude-sonnet-4-5",
sandbox: CMDC.Sandbox.Local,
backend: nil,
todos: [...],
memory_contents: %{},
user_data: %{tenant_id: "...", ...}, # 来自 Options 透传
turn: 3,
total_tokens: 1234,
cost_usd: 0.0123,
...
}几个高频字段:
ctx.working_dir— 工具操作的根目录ctx.user_data— 业务自定义数据,从create_agent(opts ++ user_data: ...)透传ctx.session_id— 当前会话 ID(多租户隔离 key)ctx.sandbox— 文件操作代理(见下节)
完整字段见 CMDC.Context。
Sandbox 代理模式
文件类工具不要直接调 File.read/1 / :os.cmd/1。走 Sandbox 代理:
@impl true
def execute(%{"path" => path}, %{sandbox: sandbox} = ctx) when not is_nil(sandbox) do
sandbox.read_file(path, working_dir: ctx.working_dir)
end
def execute(%{"path" => path}, ctx) do
CMDC.Sandbox.Local.read_file(path, working_dir: ctx.working_dir)
end为什么:
- 单元测试可以注入 mock Sandbox,不依赖真实文件系统
- 生产可切到
Sandbox.Docker/Sandbox.Modal等远端实现 virtual_mode: true时 Sandbox 会校验路径不能逃逸working_dir,防 traversal
Backend 的关系:未来版本 CMDC.Sandbox extends CMDC.Backend,目前两个
behaviour 并存。新 Tool 直接读 ctx.backend(如果非 nil)即可:
@impl true
def execute(args, %{backend: backend} = ctx) when not is_nil(backend) do
case backend.read(args["path"], offset: 0, limit: nil) do
%CMDC.Backend.Results.ReadResult{error: nil, content: content} ->
{:ok, content}
%CMDC.Backend.Results.ReadResult{error: reason} ->
{:error, to_string(reason)}
end
end参数 schema
parameters/0 返回标准 JSON Schema map。
Provider 层(req_llm)会自动转换为各家 LLM 的 tool schema 格式。
惯例:
"type": "object"在最外层- 每个属性都填
"description",LLM 看得到 "required"数组列必填字段- 默认值用
"default"字段(LLM 可能忽略,所以你的 execute 也要做默认)
错误处理三层
| 层 | 谁负责 | 例子 |
|---|---|---|
| L1 参数校验 | Tool 自己 | {:error, "missing 'path' argument"} |
| L2 业务失败 | Tool 自己 | {:error, "file not found: /etc/secret"} |
| L3 Crash | ToolRunner 兜底 | raise → 注入合成 error result,不影响其他工具 |
L1 / L2 写人类可读的英文(LLM 大多数训练语料是英文,自我纠正更稳);L3 不
建议主动用,除非你想让 Options.tool_max_retries 重试。
范例 1 — HTTP API 调用
defmodule MyApp.WeatherTool do
@behaviour CMDC.Tool
@impl true
def name, do: "get_weather"
@impl true
def description, do: "获取指定城市的当前天气"
@impl true
def parameters do
%{
"type" => "object",
"properties" => %{
"city" => %{"type" => "string", "description" => "城市英文名,如 'shanghai'"}
},
"required" => ["city"]
}
end
@impl true
def execute(%{"city" => city}, _ctx) do
case Req.get!("https://wttr.in/#{city}?format=3") do
%{status: 200, body: body} -> {:ok, String.trim(body)}
%{status: code} -> {:error, "weather API returned #{code}"}
end
rescue
e -> {:error, "weather lookup failed: #{Exception.message(e)}"}
end
end范例 2 — 数据库查询(带 user_data 多租户)
defmodule MyApp.QueryDB do
@behaviour CMDC.Tool
@impl true
def name, do: "query_db"
@impl true
def description, do: "在用户的数据库分片上执行只读 SQL"
@impl true
def parameters do
%{
"type" => "object",
"properties" => %{
"sql" => %{"type" => "string", "description" => "只读 SELECT 语句"}
},
"required" => ["sql"]
}
end
@impl true
def execute(%{"sql" => sql}, %{user_data: %{tenant_id: tid}} = _ctx) do
if String.starts_with?(String.trim(sql), "SELECT ") do
MyApp.Repo.with_tenant(tid, fn ->
case MyApp.Repo.query(sql) do
{:ok, %{columns: cols, rows: rows}} ->
{:ok, format_table(cols, rows)}
{:error, %{message: msg}} ->
{:error, "SQL error: #{msg}"}
end
end)
else
{:error, "only SELECT statements allowed"}
end
end
def execute(_, _), do: {:error, "missing tenant_id in user_data"}
defp format_table(cols, rows) do
[Enum.join(cols, " | ") | Enum.map(rows, &Enum.join(&1, " | "))]
|> Enum.join("\n")
end
end挂载时把 tenant_id 透传进去:
{:ok, session} = CMDC.create_agent(
model: "...",
tools: [MyApp.QueryDB],
user_data: %{tenant_id: "acme-corp"}
)范例 3 — 副作用工具(写 todos)
defmodule MyApp.AddTodo do
@behaviour CMDC.Tool
@impl true
def name, do: "add_todo"
@impl true
def description, do: "向用户的 todo 列表追加一项"
@impl true
def parameters do
%{
"type" => "object",
"properties" => %{
"title" => %{"type" => "string"},
"due" => %{"type" => "string", "description" => "ISO 8601 deadline,可选"}
},
"required" => ["title"]
}
end
@impl true
def execute(%{"title" => title} = args, ctx) do
todo = %{
id: Ecto.UUID.generate(),
title: title,
due: Map.get(args, "due"),
created_at: DateTime.utc_now()
}
new_todos = ctx.todos ++ [todo]
{:effect, {:update_context, :todos, new_todos}}
end
end{:effect, ...} 不会写 tool_result 进对话历史,但 Agent 会按 effect 类型
执行副作用(这里更新 ctx.todos),并 emit {:todo_change, sid, todos} 事件。
11 个内置 Tool
| Tool | 用途 | 走 Sandbox? |
|---|---|---|
| ReadFile | 读文件(offset / limit 分页) | ✓ |
| WriteFile | 写文件(覆盖) | ✓ |
| EditFile | 字符串替换 | ✓ |
| Shell | 执行命令(大输出自动落临时文件) | ✓ |
| Grep | 正则 + glob 搜索 | ✓ |
| Glob | 模式匹配文件名 | ✓ |
| ListDir | 列目录 | ✓ |
| Task | 派发子代理 | — |
| WriteTodos | 更新 ctx.todos | — |
| AskUser | 主动向用户提问 | — |
| CompactConversation | 手动触发上下文压缩 | — |
下一步
- 写一个 Plugin — 在工具调用前后做切面拦截
- 常见配方 — Tool + Plugin + Backend 的端到端组合