HARNESS 102

Tool Use

"加一个工具,只加一个 handler"

循环不用动,新工具注册进 dispatch map 就行。
这是 harness 可扩展性的第一个关键模式。

1

s01 只有一个 bash 工具,有什么问题?

s01 只用 bash 解决一切 —— 读文件用 cat,写文件用 echo > file,编辑用 sed。能跑,但很粗糙:

📏

cat 截断不可控

bash 输出被一刀切 50000 字符。读到哪断了都不知道。结构化工具可以精确控制 offset/limit。

💥

sed 遇到特殊字符就崩

文件里有 / * . 这些字符时,sed 的正则需要疯狂转义。LLM 经常写出有 bug 的 sed 命令。

🔓

每次 bash 都是不受约束的安全面

cat /etc/passwdrm -rf ../other-project——bash 能访问整个文件系统。没有路径边界。

2

核心洞察:加工具不需要改循环

这是 s02 最关键的认知。对比 s01 和 s02 的 agent_loop:

s01 — 硬编码 dispatch
for block in response.content:
    if block.type == "tool_use":
        output = run_bash(block.input["command"])
        # ↑ 只有一个工具,直接写死
s02 — 字典 dispatch
for block in response.content:
    if block.type == "tool_use":
        handler = TOOL_HANDLERS.get(block.name)
        output = handler(**block.input)
        # ↑ 字典查一次,N个工具都一样
循环的 while True、stop_reason 判断、messages 管理 — 全部没变。
变的只是 for 循环里面的 3 行代码。

如果加一个工具就要改一次循环逻辑,那有 20 个工具时循环就是一团 if/elif 面条。s02 的模式让工具数量从 1 到 100,循环复杂度不变。

🧪 案例:假如你想加第 5 个工具 "grep_file"

用它来在文件里搜索指定文本。我们看看在 s01 风格和 s02 风格下分别要怎么做:

❌ s01 风格:改循环逻辑
# 你要钻进 agent_loop 的 while 循环里面,加一个 elif:
for block in response.content:
    if block.type == "tool_use":
        if block.name == "bash":
            output = run_bash(...)
        elif block.name == "read_file":   # 每次加工具
            output = run_read(...)       # 都要来这里
        elif block.name == "write_file":  # 加一个新的
            output = run_write(...)      # elif 分支
        elif block.name == "edit_file":
            output = run_edit(...)
        elif block.name == "grep_file":   # ← 新加的!
            output = run_grep(...)       # 循环越来越胖
✅ s02 风格:只加 3 行,不动循环
# ① 写一个 handler 函数(独立的,不碰循环)
def run_grep(path, pattern):
    text = safe_path(path).read_text()
    matches = [l for l in text.splitlines() if pattern in l]
    return "\n".join(matches)

# ② 在 TOOL_HANDLERS 字典加一行(不动循环)
TOOL_HANDLERS = {
    "bash": ..., "read_file": ..., "write_file": ...,
    "grep_file": lambda **kw: run_grep(kw["path"], kw["pattern"]),  # ← 就这!
}

# ③ 在 TOOLS 列表加一个 schema(不动循环)
{"name": "grep_file", "description": "Search text in file", ...}

# Agent Loop?一个字都不用改。

👆 这就是 dispatch map 模式的核心价值:循环是封闭的,工具是开放的。加工具不需要理解循环逻辑,循环逻辑不依赖具体工具。

3

Dispatch Map:一个字典解决工具路由

整个模式就三个部分:工具 schema(给 LLM 看)+ 处理函数(给 harness 用)+ 映射表(连接两者)。

① 工具 schema 定义(TOOLS 列表)

告诉 LLM "有哪些工具、每个工具接受什么参数"。这是 Anthropic API 的 tool use 协议的一部分。

TOOLS = [ {"name": "read_file", "description": "Read file contents.", "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "limit": {"type": "integer"}}, "required": ["path"]}}, {"name": "write_file", ...}, # 每个工具一个 schema 对象 {"name": "edit_file", ...}, {"name": "bash", ...}, ]

② 处理函数(handler functions)

每个工具一个函数,接收 LLM 传的参数,执行实际操作,返回字符串结果。

def run_read(path: str, limit: int = None) -> str: text = safe_path(path).read_text() if limit: text = "\n".join(text.splitlines()[:limit]) return text[:50000] # run_write(), run_edit(), run_bash() — 每个工具一个独立函数

③ 映射表(TOOL_HANDLERS 字典)

一个字典,key 是工具名(与 schema 的 name 严格一致),value 是处理函数。这就是 dispatch map。

TOOL_HANDLERS = { "bash": lambda **kw: run_bash(kw["command"]), "read_file": lambda **kw: run_read(kw["path"], kw.get("limit")), "write_file": lambda **kw: run_write(kw["path"], kw["content"]), "edit_file": lambda **kw: run_edit(kw["path"], kw["old_text"], kw["new_text"]), }
💡 为什么用 lambda?不用 lambda 怎么写?
LLM 传来的参数是 {"path": "a.py", "limit": 20} 这种字典。lambda 的作用是把字典"拆开"传给函数。

用 lambda(代码里用的):
"read_file": lambda **kw: run_read(kw["path"], kw.get("limit"))

不用 lambda(等价写法,更易读但更啰嗦):
def handle_read(kw): return run_read(kw["path"], kw.get("limit"))
TOOL_HANDLERS = {"read_file": handle_read, ...}

两者完全等价。lambda 只是一行写完。关键理解:TOOL_HANDLERS 字典的 value 是一个"函数",不管这个函数是 lambda 还是 def 定义的。运行时用 handler(**block.input) 调用它,block.input 就是 LLM 传来的参数字典。

🧪 跟读数据流:一次 "read_file" 调用从头到尾发生了什么

假设用户在 s02 里输入 "看看 requirements.txt 里面有什么"。我们跟踪整个过程:

第①步 — LLM 返回的 response.content 里有这样一个 tool_use block:
{
  "type": "tool_use",
  "name": "read_file",       ← LLM 决定调用哪个工具
  "id": "toolu_001",
  "input": {"path": "requirements.txt"}  ← LLM 填的参数
}
第②步 — Agent Loop 中的这行代码:
handler = TOOL_HANDLERS.get("read_file")
↓ 从字典里取出 key="read_file" 对应的 value
handler = lambda **kw: run_read(kw["path"], kw.get("limit"))
第③步 — 紧接着这行代码:
output = handler(**{"path": "requirements.txt"})
↓ lambda 把字典拆开,等价于调用:
output = run_read(path="requirements.txt", limit=None)
第④步 — run_read 函数执行:
① safe_path("requirements.txt") → /Users/xxx/project/requirements.txt ✓
② 读文件内容 → "anthropic>=0.25.0\npython-dotenv>=1.0.0\npyyaml>=6.0"
③ 返回这段文本
第⑤步 — 结果被包装成 tool_result,以 user 角色塞回 messages:
{"type": "tool_result", "tool_use_id": "toolu_001",
 "content": "anthropic>=0.25.0\npython-dotenv>=1.0.0\npyyaml>=6.0"}

👆 整个过程的关键:循环代码不需要知道"read_file"是什么。它只知道:从 TOOL_HANDLERS 字典里取出一个函数,用 LLM 给的参数调用它,把返回值塞回 messages。不管未来加 10 个还是 100 个工具,这 3 行代码始终不变。

4

三个新工具,各自解决了 bash 的一个硬伤

❌ bash 方式
bash: echo 'print("hello")' > a.py
# 问题:引号嵌套容易出错
# 问题:不能 mkdir -p 父目录
# 问题:内容含特殊字符就崩
✅ write_file
write_file: path="a.py"
            content='print("hello")'
# 自动创建父目录
# 内容原样写入,不需要转义
# 返回写入字节数供验证
❌ bash 方式
bash: sed -i 's/old_text/new_text/' file.py
# 问题:old_text 含 / 就崩
# 问题:特殊字符需要疯狂转义
# 问题:LLM 经常写出有 bug 的 sed
✅ edit_file
edit_file: path="file.py"
           old_text="exact string"
           new_text="replacement"
# Python 字符串精确匹配+替换
# 不涉及任何 shell 转义
# 找不到 old_text 会报错
❌ bash 方式
bash: cat long_file.py
# 输出在 50000 字符处被一刀切
# 不知道切在哪一行
# 想读后半段?没法指定 offset
✅ read_file
read_file: path="long_file.py"
           limit=50
# 按行限制,不丢完整行
# 返回 "(N more lines)" 提示
# 可精确控制读取范围
5

代码拆解:s02 的四个组成部分

A

初始化:多了安全路径的基准

s02 新增了 WORKDIRsafe_path()。所有文件操作都基于 WORKDIR,作为路径沙箱的锚点。

WORKDIR = Path.cwd() # 工作目录锚点 def safe_path(p: str) -> Path: path = (WORKDIR / p).resolve() if not path.is_relative_to(WORKDIR): # 防止 ../ 逃逸 raise ValueError(...) return path
B

工具实现:四个独立的处理函数

每个函数内聚一个工具的全部逻辑。路径安全、输出截断、错误处理全在函数内部。

def run_read(path, limit=None): def run_write(path, content): text = safe_path(path).read_text() fp = safe_path(path) if limit: fp.parent.mkdir(parents=True, exist_ok=True) text = "\n".join(...[:limit]) fp.write_text(content) return text[:50000] return f"Wrote {len(content)} bytes" def run_edit(path, old_text, new_text): def run_bash(command): fp = safe_path(path) # (与 s01 相同) text = fp.read_text() if old_text not in text: return "not found" fp.write_text(text.replace(old_text, new_text, 1)) return f"Edited {path}"
C

Dispatch:字典映射 + 循环中调用

TOOL_HANDLERS 字典(上一节已展示)是静态的映射表。循环中的调用只需要一行查找:handler = TOOL_HANDLERS.get(block.name)

# Agent Loop 中,工具执行部分(仅改 3 行) for block in response.content: if block.type == "tool_use": handler = TOOL_HANDLERS.get(block.name) # 字典查找 output = handler(**block.input) if handler # 解包调用 else f"Unknown tool: {block.name}" # 工具不存在时的兜底 results.append({"type": "tool_result", ...})
💡 对比:如果不用 dispatch map 而用 if/elif
4个工具就是 4 个 if 分支,20 个工具就是 20 个分支。dispatch map 让工具数量增长时,调用复杂度始终是 O(1) 字典查找。
D

外层:完全不变

REPL 循环、history 管理、输入输出处理 —— 和 s01 一模一样。这就是关键:工具层面的改动不影响外层架构。

6

safe_path:路径沙箱是最小但最关键的安全机制

给 Agent 文件读写能力后,第一个安全问题就是路径逃逸。Agent 可能被诱导读写工作区外的文件:

❌ 没有 safe_path
path = "../../../.ssh/id_rsa"
open(path).read()  # 读到了!
✅ 有 safe_path
path = "../../../.ssh/id_rsa"
safe = (WORKDIR / path).resolve()
# → /Users/xxx/.ssh/id_rsa
if not safe.is_relative_to(WORKDIR):
    raise ValueError("Path escapes!")

safe_path 做了什么

拼接WORKDIR / user_path — 把用户给的相对路径拼到工作目录下。

resolve:解析所有 .. 和符号链接,得到真实的绝对路径。

检查is_relative_to(WORKDIR) — 真实路径是否还在工作目录内?不在就报错。

注意:safe_path 不止是安全检查。它让 Agent 可以用相对路径操作("a.py" 而不是 "/full/path/a.py"),LLM 不需要知道绝对路径。

7

s01 vs s02:什么变了,什么没变

组件s01s02
工具数量 1(仅 bash) 4(bash, read_file, write_file, edit_file)
Dispatch 方式 硬编码:直接调用 run_bash 字典映射:TOOL_HANDLERS.get(block.name)
路径安全 safe_path() 沙箱
Agent Loop 完全不变(while True、stop_reason、messages 管理都一样)
外层 REPL 完全不变
System Prompt 微小调整:从 "Use bash" 变成 "Use tools"
8

从 s02 带走的三个核心认识

01

循环是稳定的,工具是可扩展的

Agent Loop 不会因为加工具而改变。好的架构让核心稳定,让扩展点开放。dispatch map 就是这个扩展点。

02

专用工具 > 通用工具

bash 能做任何事,但 read_file/write_file/edit_file 在各自领域更好。精确的参数 schema = LLM 调用更准确。工具层面的安全检查 = bash 做不到的沙箱。

03

加工具 = 加 handler + 加 schema

以后你要加新工具(grep、git、web),只需要三步:写一个 handler 函数 → 定义一个 schema → 在 TOOL_HANDLERS 字典里加一行。循环碰都不碰。

9

落地案例:Dispatch Map 在真实 AI 产品中的应用

学完 Tool Use 之后,你会注意到所有成熟的 AI Agent 产品都有一套"工具注册系统"。以下是三个真实的 Agent 工具系统案例。

🔌 场景一:MCP(Model Context Protocol)— Anthropic 的工具标准

Anthropic 发布的 MCP 协议 就是 s02 的 dispatch map 工业化版本。它定义了一个标准:AI 应用如何发现、注册、调用外部工具。

MCP Server = 一个工具包(比如 GitHub MCP Server 提供 create_issue、list_prs、search_code 等工具)。
MCP Client(Claude Code)= 自动发现 Server 提供的工具 → 注册到 TOOLS 列表 → LLM 调用时通过 dispatch 分发。

你正在用的 Claude Code 支持 MCP——你在设置里加一个 MCP Server,就相当于往 TOOL_HANDLERS 字典里批量注册了一组工具。不需要改 Claude Code 的核心逻辑。这就是 s02 的 dispatch map 模式在工业级产品中的样子。

🔍 场景二:OpenAI Function Calling — ChatGPT 的插件生态

ChatGPT 的"插件"和"Function Calling"功能就是 s02 的 TOOLS 列表 + dispatch map:

TOOLS 列表:每个插件声明自己的 functions(名称 + 参数 schema)→ 注册到 ChatGPT 的工具列表。
Dispatch:用户说"帮我订一张明天去上海的机票"→ LLM 判断需要调用"携程插件"的 search_flights function → dispatch 到这个 function → 返回航班列表。

OpenAI 的开发者文档里教你定义 function schema 的方式,和 s02 里定义 TOOLS 的方式完全一致:name、description、parameters(就是 input_schema)。你学完 s02 之后,可以直接看懂 OpenAI 的 function calling 文档。

🛠️ 场景三:LangChain 的 Tool 抽象

LangChain 是 Python 生态里最流行的 Agent 框架。它的 Tool 系统本质就是 s02 的 dispatch map:

# LangChain 的工具定义(和 s02 的 TOOLS 结构一样) from langchain.tools import tool @tool def search_web(query: str) -> str: """Search the web for information.""" return web_search(query) # agent 内部:TOOL_HANDLERS["search_web"](query="...")

LangChain 用装饰器(@tool)自动把 Python 函数转成工具 schema + handler。但底层原理和 s02 完全一样:一个函数 = 一个工具,一个名字 = dispatch key,加工具不改循环。你理解了 s02 的 3 行 dispatch 代码,就理解了 LangChain 几百行 Tool 抽象背后的核心思想。

核心迁移:所有 Agent 工具系统——MCP、OpenAI Function Calling、LangChain Tools——底层都是 s02 的 dispatch map 模式。你不需要学每个框架的工具系统,你只需要理解:工具 = name + schema + handler function,dispatch = 字典查找。换任何框架,核心不变。

s02 有了多个工具,但 Agent 接到复杂任务还是容易跑偏。
s03:怎么让 Agent 先列计划、再动手?

s03: TodoWrite →