跳到主要内容

数据截至 (上游 commit fc74d079a18c)

工具与集成:@tool、MCP 冻结面与能力清单

30 秒导读: 大模型只会输出文字,顶多输出一段"我想调用 lookup_ticket(...)"的意图。 本章讲 v3 怎把这段意图精确、安全地变成一次真实调用,以及——更重要的——怎么保证 "现在允许调的工具"和"部署时允许调的工具"是同一批。三件套撑起这件事: 内容哈希冻结的工具面、按契约分级的行为约束、deny-by-default 的能力清单。

本章是 全景 里"手脚"那一支。IR 与契约的数据结构见 01;callTool activity 在执行引擎里的位置见 02;在 @flow 里怎么写工具步见 03

旧版对照(已移除): v1 的四类工具分发(function/integration/api_call/system)、 integrations-service 微服务(16 个 provider 注册表)、run_llm_with_tools 循环、 feature flag 灰度全部不存在。v3 只有两种工具引用 + 一个统一调用面


1. 这是什么(零基础也能懂)

先建立一个直觉

tool calling(工具调用)的三步老规矩:告诉模型有哪些工具 → 模型回一段结构化意图 → 你的系统真的去执行。第 3 步看着简单,坑最深:

  • 工具的 schema 半夜被上游改了,流程还按老 schema 调,炸。
  • "只读"的工具其实有副作用,重试一次重复扣款。
  • 模型调了一个你从没授权的工具。
  • 密钥散落在每次调用的参数里,进了日志。

v3 对这四个坑各有一个机制:冻结(freeze)、契约(contract)、清单(manifest)、 秘密(secrets)。本章按"一次工具调用的一生"讲它们在哪起作用。

两种工具引用

IR 里 ToolRef 只有两种(julep/ir.py:61/:72):

引用是什么怎么调
NativeTool(name)自家的 HTTP 工具(Cloud Run/Lambda)callTool 直接 POST WorkerContext.tool_urls[name]
McpTool(server, tool)来自某个 MCP 服务器的工具callTool 经注入的 MCP caller 调

用起来什么样

# 示意,非源码:定义、声明引用、在 @flow 里调用
@tool(effect="read", idempotent=True) # 契约:只读 + 幂等(julep/agent.py:354)
def lookup_ticket(ticket: str) -> dict[str, str]: ...

episodes = mcp_tool("episodes", "get_episode") # MCP 引用(julep/mcp_step.py:33)

@flow
def triage(ticket: str):
hit = lookup_ticket(ticket, retries=2) # native 步
raw = episodes(episode_id=hit["id"]) # MCP 步(handle kwargs 直接构成输入记录)
...
deployment = deploy(triage, tools=[lookup_ticket], ...) # 冻结面(见 §3)

2. 顶层全景:一次工具调用的一生

怎么读这张图: 从左到右是时间轴。上排是编译期(冻结),下排是运行期(每次调用)。 每个菱形是一次校验。

编译期(部署一次)
─────────────────────────────────────────────
@tool / mcp_tool 引用 ─▶ freeze(julep/freeze.py:504)
│ 工具 schema → definition_hash
│ 回填 CallStep.frozen_hash

deploy 五道门(julep/deploy.py:904)
能力执行:引用必须被 manifest 授予
审批闸:dangerous 工具必须被人闸支配
race 准入:竞速分支必须只读/断言幂等
─────────────────────────────────────────────
运行期(每次调用)
interpret ─▶ PRIM(CallStep) ─▶ callTool activity(julep/execution/effects.py:909)
│ MCP:冻结 schema 校验输入 → MCP caller(preflight 已保证面没漂)
│ native:网络出网域校验 → POST {input,cache} + Idempotency-Key: cid

轨迹捕获(尽力而为,不影响结果)

部件一句话职责:

部件干什么在哪
@tool 装饰器把函数包成 Tool,声明 effect/idempotent,从类型注解推 schemajulep/agent.py:354(tool)/:228(Tool)
mcp_tool声明一个 MCP 工具引用(schema 由冻结快照提供)julep/mcp_step.py:33
freeze快照 → 内容哈希清单,绑定每个 calljulep/freeze.py:504
CapabilityManifest允许清单:工具/reasoner/作用域/服务器/预算julep/capabilities.py:85
callTool统一调用 activity(MCP 或 HTTP)julep/execution/effects.py:909
MCP preflight起跑前校验冻结面 vs 现场面julep/execution/mcp_preflight.py
mcp_authMCP 传输/鉴权,secret:// 头引用julep/mcp_auth.py:803
skills渐进披露的指令块(SKILL.md)julep/skills.py:60

3. 核心机制一:冻结——工具面按内容哈希钉死

它要解决的小问题: "流程引用的工具"和"实际存在的工具"必须一致,否则部署物是空头支票。

思路: 部署时把每个引用工具的身份 + schema 算成 definition_hash (julep/contracts.py:148),存进 ToolManifest,并把这个哈希回填到 IR 里每个 CallStep.frozen_hash(julep/freeze.py:504)。运行期 bind(julep/freeze.py:620) 按哈希查回真实工具定义。

快照有两个来源(julep/freeze.py):

  • McpToolSpec(:70)——真实调用 MCP tools/list 拿到的 schema;
  • NativeToolSpec(:89)——本机注册的 @tool(schema 从类型注解推导, julep/agent.py:180 _schema_from_hints)。

README.md:78 说的"contract comes from the frozen MCP snapshot"就是这条路: @flow 里写 mcp_tool(server, tool) 时不需要任何网络,部署时才解析、冻结。

冻结时机是显式的缝(julep/deploy.py:1 docstring):默认 deploy_time(一次冻结, 最大确定性);工具面会在部署之间漂移的场景用 per_run + Deployment.refresh (julep/deploy.py:615)对新鲜快照重冻结。


4. 核心机制二:契约与重试——"未断言即保守"

它要解决的小问题: 这个工具能不能重试?能不能放进 race?没人说得清时怎么办?

思路: 每个工具带一份 ToolContract(julep/contracts.py:22)= effect(对世界做了 什么)+ idempotency(重复安全吗)。拿不到可信信息时一律保守:CONSERVATIVE_DEFAULT (julep/contracts.py:40)按 write + none 处理——于是:

  • 重试:只有"断言过契约"且契约允许的调用才按 Ann.max_attempts 重试 (julep/execution/interpreter.py:100 起,详见第 02 章 §4);
  • race:race/hedge/quorum 的分支必须只读或断言幂等(julep/derived.py:324);
  • 审批:dangerous/清单点名的工具必须被人闸支配(check_approval_gates, julep/capabilities.py:436)。

断言的唯一受信来源是能力清单。 MCP 工具自己的注释性提示按规范不可信, contract_from_annotations(julep/contracts.py:111)没看到明说就塌缩到保守默认; 只有人写的 CapabilityManifest.overrides(julep/capabilities.py:214)能把某工具 "断言"成 read/native(julep/contracts.py:1 docstring)。

@tool 装饰器(julep/agent.py:354)让你在源码里就地声明契约:

# 真实签名(julep/agent.py:354 起)
def tool(fn=None, /, *, effect: str = "write", idempotent: bool = False, name=None): ...

注意默认值就是保守面:effect="write"idempotent=False——不写注解 = 承认自己 不可盲目重试


5. 核心机制三:能力清单——deny-by-default 的允许面

CapabilityManifest(julep/capabilities.py:85)是人写的合同:"这个部署允许碰什么"。 允许面覆盖五类:工具(ToolGrant,julep/capabilities.py:63,含 maxCalls 次数上限)、 reasoner、上下文作用域、MCP 服务器、总预算(Budget :49)。

语义:缺段 = 不约束;出现段 = 白名单。(julep/capabilities.py:1 docstring) ——不列 reasoners 谁都行;列了两个,第三个被拒。

它在三个缝上被执行(同 docstring):

检查什么在哪
编译每个引用的工具/reasoner/作用域/服务器必须被授予deploy() 的能力执行门(julep/deploy.py:904)
调度预算暴露给 agent 守卫与计划估价julep/agent_loop.py:585
运行网络出网域 + 模型允许清单,在 activity 内查callToolcapabilities.network_allows(julep/capabilities.py:351)

运行期那道很实在:callTool 对 native 工具先取 URL 域名,network_allows 不放行就 抛 CapabilityDenied(julep/execution/effects.py:909 起)——清单不是纸面约束。


6. 核心机制四:调用落地——MCP caller 与幂等键

callTool(julep/execution/effects.py:909)的运行期逻辑分两路:

MCP 路:先按冻结 schema 校验输入(不合法抛 ToolInputValidation,这一步防"模型 编造的字段"直接打到服务器),再调注入的 _CTX.mcp_call(server, tool, value, cid, principal, secrets, input_schema_validated)。MCP caller 的鉴权由 mcp_auth 处理: 支持 secret://name 形式的整串头引用——值只在加密 Temporal 载荷里旅行,不进存储、 不进投影(julep/mcp_auth.py:31_SECRET_REF 与 README.md 的 "Production secrets and MCP safety" 节);transport_for_mcp_caller(julep/mcp_auth.py:803)为流式 caller 构造传输。

native 路:查 WorkerContext.tool_urls(julep/execution/effects.py:106)拿 URL → 网络域校验 → HTTP POST。两个值得记住的头:

  • Idempotency-Key: cid——激活 id 即幂等键。服务端兑现这个键,重试才敢发生; 它和投影事件、人闸等待键共用同一个确定性计数(第 02 章 §9)。
  • principal 头(可选)——把租户/凭据引用传给工具服务,是引用不是值

7. 核心机制五:preflight——起跑前防面漂移

它要解决的小问题: 部署时冻结的面,起跑时可能已经变了(上游 MCP 加了字段、删了工具)。 跑一半发现工具没了是灾难;带上 secrets 跑更是灾难。

思路: run 启动时(第一次效果发生前)做一次 MCP preflight,把冻结清单与新鲜的 tools/list 对比。策略三档 McpSurfacePolicy(julep/mcp_surface.py:18):

行为
pin(新默认)每个工具的 definition_hash 必须逐字节一致
names只查工具名集合,不比 schema
off跳过(逃生门)

对比逻辑在 compare_mcp_surface(julep/mcp_surface.py:150),产出 McpSurfaceMismatch 明细(frozen hash vs fresh hash vs diff);assert_mcp_surface (:213)把 mismatch 变成类型化错误。Temporal 侧包装成 activity preflightMcp (julep/execution/harness.py:217,30 秒 TTL 的进程内缓存,julep/execution/mcp_preflight.py:28 起)。带 run secrets 的 run 强制要求已完成 preflight 的绑定状态,否则工作流直接拒绝 (julep/execution/harness.py:1496 起)。README.md 的说法:"mid-run tool removal or post-validation schema rejection fails terminally as typed surface drift"。


8. 核心机制六:agent skills——渐进披露的指令块

Skill(julep/skills.py:60)是 dotctx 包旁边 skills/<name>/SKILL.md 的一块可复用 指令:YAML frontmatter(name/description)+ markdown 正文。设计有三个点:

  • 显式激活:reasoner 的 skills: 设置点名才生效,目录存在本身不改变任何行为 (julep/skills.py:1 docstring)。
  • 渐进披露:系统提示只带名字+描述;正文等模型通过保留工具 __load_skill__ (SKILL_TOOL,julep/skills.py:39)要的时候才给,且从冻结 release 解析,不读 文件系统——重放确定性。
  • 内容寻址:skill/<name>@v<hash12>,字节级相同的技能跨包收敛为一个注册项和部署物里 的一份拷贝;改一个字就是新的 key——于是 Reasoner.skills 持有这些 key,改技能 = 改 reasoner 身份(同 docstring)。

9. 把它们串起来:一次带审批的调用

```text
# 示意,非源码:一个 dangerous 工具从定义到落地
@tool(effect="dangerous", idempotent=False)
def refund(charge_id: str) -> dict: ...

CapabilityManifest(tools=[ToolGrant(key="refund", approval=True)])

@flow
def refund_flow(charge_id: str):
ok = human_gate(prompt="approve refund?") # 人闸(部署门要求:dangerous 必须被它支配)
if_side = ...
return refund(charge_id) # 经 cond(ok) 分支后调用
```

部署时:freeze 把 refund 钉到哈希;能力门确认 refund 被授予;审批门确认调用路径上有 __human_gate__ 支配。运行时:gate 挂在 openGates 等人;人点了 submitHuman; cond 放行;callTool 发 POST,带 Idempotency-Key;效果落轨迹。五道防线各管一段, 没有一道是"事后审计"。


10. 巧妙之处(可借鉴的技术)

  • "未断言即保守"的默认面。 外部注解不可信就全部塌缩成 write+none——宁可让用户 多写一行断言,不给静默危险留门。见 julep/contracts.py:40

  • cid 一钥三用。 同一个确定性激活 id 兼任投影事件标识、HTTP 幂等键、人闸等待键, 观测/幂等/人机交互用一根线串起。见 julep/execution/effects.py:909

  • 冻结时机做成显式参数。 deploy_time 换确定性、per_run 换新鲜度,由部署者按工具面 是否会漂移来选——不假装两种需求不存在。见 julep/deploy.py:615

  • preflight 三档策略 + 强制绑定。 默认 pin 到哈希,给 names/off 逃生门;但只要你带 run secrets,就没有"关掉 preflight"这个选项。见 julep/mcp_surface.py:18julep/execution/harness.py:1496

  • secret:// 整串头引用。 密钥值只在加密载荷里存在,存储/投影/轨迹一律脱敏; 失败信息也要过 redactor(scrub_mcp_preflight_failure,julep/execution/mcp_preflight.py:53)。

  • 技能内容寻址进 reasoner 身份。 提示词资产的版本管理与代码同等待遇:改一个字, 身份变,部署物变。见 julep/skills.py:1 docstring。


11. 边界与局限(诚实)

  • 没有 v1 式的"现成集成货架"。 v1 的 integrations-service 内置 16 个 provider (wikipedia/brave/email/browserbase…),v3 主干没有等价物——外部能力一律走 MCP (或自己写 HTTP 工具服务)。想要"开箱即用的浏览器/搜索集成"得另接 MCP 服务器。
  • native 工具要求你自己托管 HTTP 服务。 @tool 函数在 dry_run/本地跑是真的 Python 调用,但生产部署物里 native 引用指向 WorkerContext.tool_urls——工具的 生产形态是一个 HTTP 端点。
  • MCP caller 是注入的,不是内建的。 julep[mcp] extra 提供 SDK 支持,但具体 caller (传输、鉴权、重试栈)由 worker 配置注入(julep/execution/effects.py:106WorkerContext.mcp_call)。
  • 能力清单不约束 MCP 服务器内部。 清单管"能连哪些 server、调哪些工具",工具自己 再调了什么外部系统,框架看不见(那是 server 运维者的责任边界)。

12. 代码地图(导航索引)

主题文件路径符号名
@tool 装饰器 / Tooljulep/agent.py:354tool / Tool(:228) / _schema_from_hints(:180)
类型注解 → JSON Schemajulep/agent.py:137python_type_to_schema
工具引用julep/ir.py:61NativeTool / McpTool(:72) / toolref_key(:94)
MCP 引用步julep/mcp_step.py:33mcp_tool / McpToolStep(:14)
工具契约julep/contracts.py:22ToolContract / CONSERVATIVE_DEFAULT(:40)
注解 → 契约(保守)julep/contracts.py:111contract_from_annotations
定义哈希 / 冻结工具julep/contracts.py:148definition_hash / FrozenTool(:201) / execution_hash(:185)
冻结管线julep/freeze.py:504freeze / McpToolSpec(:70) / NativeToolSpec(:89) / bind(:620)
从工具构快照julep/agent.py:383snapshot_from_tools
能力清单julep/capabilities.py:85CapabilityManifest / ToolGrant(:63) / Budget(:49) / overrides(:214)
审批闸julep/capabilities.py:436check_approval_gates
网络出网白名单julep/capabilities.py:351network_allows
统一调用 activityjulep/execution/effects.py:909callTool
worker 上下文(注入面)julep/execution/effects.py:106WorkerContext / configure(:430)
MCP preflight(后端中立)julep/execution/mcp_preflight.py:105preflight_mcp / McpPreflightError(:40)
preflight activityjulep/execution/harness.py:217preflightMcp
面对比/断言julep/mcp_surface.py:150McpSurfacePolicy(:18) / compare_mcp_surface / assert_mcp_surface(:213)
MCP 鉴权/secret 头julep/mcp_auth.py:803transport_for_mcp_caller / _SECRET_REF(:31)
密钥库/脱敏julep/secrets.py:400operator_secret_redactor / scrubber_for_values(:343)
技能julep/skills.py:60Skill / SKILL_TOOL(:39) / load_package_skills(:168)
人闸组合子julep/derived.py:221human_gate
race 准入julep/derived.py:324check_race_admission