这一章把散落在前面的进阶能力收在一起,然后给一份能逐条打勾的上线清单。清单比概念重要。

18.1 动态能力:一个机制,六个槽位

defineDynamic 用同一个解析器接口,按会话事件决定模型、子代理、连接、工具、技能、指令。

import { defineDynamic } from "eve";

export default defineDynamic({
  events: {
    "session.started": (_event, ctx) => { /* 返回能力或 null */ },
    "turn.started": (_event, ctx) => { /* … */ },
    "step.started": (_event, ctx) => { /* 仅模型和工具支持 */ },
  },
});
槽位 支持的事件 必须返回什么 名字从哪来 返回 null
模型 session / turn / step 模型 ID 字符串;{ model, reasoning?, modelContextWindowTokens?, modelOptions? };LanguageModel 对象(仅 step) 不适用 不允许,turn 直接失败
子代理 session / turn defineAgent({ description, model, … }) 所在目录名 不暴露给父模型
连接 session / turn defineMcpClientConnection(…) 或 defineOpenAPIConnection(…),或它们的 map 单个用文件名;map 用 key 不暴露
工具 session / turn / step defineTool({ … }),或它的 map 单个用文件名;map 用 key 不暴露
技能 session / turn defineSkill({ … }),或它的 map 单个用文件名;map 用 key 不暴露
指令 session / turn defineInstructions({ content, role? }) 不适用 不贡献内容

这张表里最容易忽略的是「必须返回什么」:每个条目都要用对应的 define* 包一层,直接返回一个裸对象不会被接受。

模型那一行还有一条序列化规则:session.started 和 turn.started 只能返回字符串(或 model 为字符串的选择对象)。LanguageModel 对象(比如直连 DeepSeek 的 deepseek("deepseek-v4-pro"))不可序列化,只能从 step.started 返回。所以「按租户切到国产模型」要么用 Gateway 字符串,要么把切换放进 step。

defineDynamic 的字段:

字段 类型 必填 说明
events { "session.started"?, "turn.started"?, "step.started"? } 是 每个 key 是一个解析器,签名 (event, ctx) => 能力 \| null
build { externalDependencies?: string[] } 否 只在外层 defineDynamic 上有效,要在解析器运行前生效

返回值的三种形状

形状一:单个定义。 子代理、指令只能这样返回:

// agent/subagents/finance/agent.ts
import { defineAgent, defineDynamic } from "eve";

export default defineDynamic({
  events: {
    "session.started": (_event, ctx) =>
      ctx.session.auth.current?.attributes.plan === "enterprise"
        ? defineAgent({
            description: "分析财务和会计数据。",
            model: "anthropic/claude-sonnet-5",
          })
        : null,
  },
});

形状二:map。 工具、连接、技能可以一次返回一组,key 就是名字:

// agent/tools/query.ts
import { defineDynamic, defineTool } from "eve/tools";
import { z } from "zod";
import { listTables, runReadOnly } from "../lib/warehouse";

export default defineDynamic({
  events: {
    "session.started": async () =>
      Object.fromEntries(
        (await listTables()).map((t) => [
          t.name,                                  // 模型看到 `orders`、`users`……
          defineTool({
            description: `查询 ${t.name}。列:${t.columns.join(", ")}`,
            inputSchema: z.object({ sql: z.string() }),
            execute: ({ sql }) => runReadOnly(t.name, sql),
          }),
        ]),
      ),
  },
});

工具和技能的 map key 有硬约束:只用 ASCII 字母、数字、下划线、连字符,首字符必须是字母,长度不超过 64。违反时 eve 记日志并跳过这个结果——不会报错,只是「东西不见了」,所以这类问题很难查。

连接的 map key 直接成为连接名,同时占住 <name>__ 这个工具前缀。所以 key 不能和已有的工具、子代理、别的连接撞名。

形状三:模型选择对象。 模型可以是字符串,也可以带覆盖项:

model: defineDynamic({
  events: {
    "step.started": (_event, ctx) =>
      hasImageInput(ctx)
        ? { model: "google/gemini-3.5-flash", reasoning: "low" }
        : { model: "zai/glm-5.2" },
  },
}),

带对象的写法在换模型的同时可以改 reasoning、modelContextWindowTokens、modelOptions。只换模型 ID 时用字符串更省事,上下文窗口由 eve 从 Gateway 目录查。hasImageInput 在这里只是示意,完整的图片检测逻辑见本节末尾的「按消息内容切模型」。

三条执行规则

  1. 优先级 step > turn > session。 最近触发的那个拥有该文件的槽位集合。
  2. 解析器在编译期跑一次做分类校验,运行时再跑一次。 所以顶层代码在两个阶段都会执行,会话相关的逻辑必须放进 handler。
  3. 失败即失败。 模型解析器抛错或不返回具体模型,turn 在调用 provider 之前就失败。其它槽位的失败会记录并跳过该结果。

模型解析器优先用 session.started:prompt 缓存按模型分,中途换模型等于按未缓存价格重读整段对话。

三个事件的作用范围是逐层覆盖的:

flowchart TD
  S["session.started<br/>给出基础能力集"] --> T["turn.started<br/>覆盖同名槽位"]
  T --> ST["step.started<br/>再覆盖(仅模型 / 工具)"]
  ST --> R["本轮这一步实际生效的能力"]
  S -.->|"会话级:模型、子代理、连接、工具、技能、指令"| R
  T -.->|"轮级:同上,全部六个槽"| R
  ST -.->|"步级:只有模型和工具"| R

越靠后的事件优先级越高,但能力越窄:step.started 只能改模型和工具,改不了连接和指令。所以「按租户换连接」要放在 session.started,「这一轮改用便宜模型」可以放在 turn.started。

冲突规则

情况 结果
动态条目和手写条目同名 动态覆盖手写的
动态条目撞上 workflow 工具或子代理 报错,跳过该解析器的结果
两个动态解析器产出同名 报错(真歧义),需要自己加命名空间

一个实用模式:按消息内容切模型

model: defineDynamic({
  events: {
    "step.started": (_event, ctx) => {
      const hasImage = ctx.messages.some(
        (m) => m.role === "user" && Array.isArray(m.content) &&
          m.content.some((p) => p.type === "image" ||
            (p.type === "file" && String(p.mediaType).startsWith("image/"))),
      );
      return hasImage ? "google/gemini-3.5-flash" : "zai/glm-5.2";
    },
  },
}),

图片在 step.started 之前已经被 staged 到 /workspace/.eve/attachments,但 ctx.messages 里保留了 media type,所以能判断。

18.2 扩展包

需要跨 agent 复用能力时,打成扩展。

能贡献什么

@acme/crm/
├── package.json
└── extension/
    ├── extension.ts
    ├── tools/search.ts
    ├── channels/webhook.ts
    ├── connections/api.ts
    ├── skills/triage/SKILL.md
    ├── schedules/sync.ts
    ├── subagents/reviewer/agent.ts
    ├── instructions.md
    ├── hooks/audit.ts
    └── lib/http.ts

不能贡献:agent 配置、instrumentation、memory 槽、沙箱、嵌套扩展。这些是消费方 agent 的事。

创建与挂载

npx eve@latest extension init my-crm
// agent/extensions/crm.ts
import crm from "@acme/crm";

export default crm({ apiKey: process.env.CRM_API_KEY! });

挂载名成为命名空间:crm__search、crm__webhook、crm__sync、crm__api、crm__reviewer。通道保留自己的路由路径,定时任务保留自己的 cron 表达式。

三条约束:

  • 挂载名不能和连接同名(连接占住 <name>__ 整个前缀)。
  • 不能叫 eve(框架保留)。
  • 每个挂载绑定自己的配置;移动或重命名挂载等于换一个实例,它的 state 键也会变。

配置 schema

// extension/extension.ts
import { defineExtension } from "eve/extension";
import { z } from "zod";

export default defineExtension({
  config: z.object({
    apiKey: z.string(),
    baseUrl: z.string().url().default("https://api.acme.example"),
  }),
});

消费方传的配置会先校验,默认值已经填好。扩展里的贡献直接读这个句柄:

// extension/tools/search.ts
import { defineTool } from "eve/tools";
import { z } from "zod";
import extension from "../extension";

export default defineTool({
  description: "搜索 CRM。",
  inputSchema: z.object({ query: z.string() }),
  async execute({ query }) {
    const { apiKey, baseUrl } = extension.config;   // 已校验,已填默认值
    return { query, baseUrl, authenticated: apiKey.length > 0 };
  },
});

配置 schema 必须同步校验(不能返回 Promise)。不需要配置时,defineExtension() 空调用即可。

defineExtension 只有一个字段:

字段 类型 必填 说明
config 任意 Standard Schema(Zod、Valibot…) 否 消费方在挂载时传的设置;校验后的值通过 extension.config 读取

内置扩展里的两个 GitHub 钩子

eve 自带的 eve/extensions/git 把 GitHub 访问代理成 git__gh 工具。它有两个可选回调,位置在 git({ github: { … } }) 里:

选项 签名 作用
github.authorize ({ auth, repository, token }) => boolean \| Promise<boolean> 每次 gh 调用前跑,返回 false 就不执行
github.attribution (ctx) => { name, email } \| null 给提交和 PR 加 Co-authored-by

authorize 的执行顺序是:校验命令和仓库 → 铸造只作用于该仓库的 app token → await authorize(...) → 装凭据租约 → 跑命令。返回 false 时租约还没装、命令也没跑。auth 是调用那一刻的会话调用者,可能是 null。判断只对这一次调用生效,不缓存,也不对每个 GitHub API 请求重复。注意放行的命令用的是 app token 的权限,不是调用者本人的 GitHub 权限。

// agent/extensions/git.ts
import git from "eve/extensions/git";
import { brokerGitHubLease } from "../lib/github";

export default git({
  github: {
    connector: "github/my-agent",
    org: "acme",
    broker: brokerGitHubLease,
    // GitHub 通道的会话把评论者 login 放在 auth.subject 上
    async authorize({ auth, repository, token }) {
      if (auth?.authenticator !== "github-webhook" || !auth.subject) return false;
      const res = await fetch(
        `https://api.github.com/repos/${repository}/collaborators/${auth.subject}`,
        { headers: { authorization: `Bearer ${token}` } },
      );
      return res.status === 204;
    },
  },
});

gh 工具提交的作者是 GitHub App 本身。要把功劳记到具体的人身上,用 attribution 返回他:

git({
  github: {
    connector: "github",
    org: "acme",
    broker,
    attribution: (ctx) => coAuthorFor(ctx.session.auth.current),
  },
});

它会在每条 gh-signed-commit 消息末尾、以及带 --body / --body-file 的 gh pr create 正文末尾加 Co-authored-by: <name> <email>。返回 null 就不加。agent 自己写的 Co-authored-by 行会保留,并且和这条排在同一块里,仍然算数。用 --fill / --template 建的 PR,以及 gh api 这类路径,不加署名。

18.3 远程 agent

专家在另一个部署里:

// agent/subagents/weather.ts
import { defineRemoteAgent } from "eve";
import { vercelOidc } from "eve/agents/auth";

export default defineRemoteAgent({
  url: "https://weather-agent.example.com",
  description: "回答天气、温度、预报、风、雨、雪的问题。",
  auth: vercelOidc(),
});
参数 说明
url 基址,可以是函数(运行时解析)
description 必填,模型可见
auth 出站鉴权
forwardPrincipal 转发调用者身份,只在对方信任你时开
headers 静态或惰性解析的请求头
path 默认 /eve/v1/session
tool false 时只允许 ctx.agent() 调用

forwardPrincipal: true 是一个信任声明。 打开它意味着对方会把你的用户身份当作已认证的。对方的 trustedForwarders 必须明确接受你,否则不该开。

跨部署升级:两边一起升。旧版本可能不认新版本的续接和重置协议。

18.4 运行时生成的 workflow 程序

让模型自己写一段 JS 来编排子代理:

// agent/tools/workflow.ts
import { workflow } from "eve/tools/workflow";

export default workflow({ maxSubagents: 20 });

workflow() 的选项:

选项 类型 默认 说明
maxSubagents number 100 一段程序里最多调几次子代理,范围 1–128

工具对模型的输入只有一个字段:

字段 类型 说明
js string 一段 async 函数体,返回值必须 JSON 可序列化
  • 模型通过 js 提供函数体。
  • 唯一宿主能力是 ctx.agent(name, { message, outputSchema? }),直接 resolve 成结果。
  • 程序不能访问 workflow 上下文、会话状态、凭据、import、普通工具。
  • 每次调用都开新的子会话,不能续接上一次的。

适合「按情况临时决定怎么拆分任务」;代价是模型写的编排不可预测,必须有超时和预算上限。

18.5 可观测性

三层,从近到远:

1. 本地

eve logs list
eve logs show <id>
eve traces list
eve traces show <id>

trace 默认写到 .eve/traces/,按时间、大小和保留条数设上限。

2. 事件落库

agent/hooks/ 订阅 "*",按 meta.id 入库(第 15 章)。这是最可靠的自建审计,不依赖外部服务。

3. OpenTelemetry

agent/instrumentation/ 下每个文件一个 provider:

// agent/instrumentation/otel.ts
import { registerOTel } from "@vercel/otel";

registerOTel({ serviceName: "opsbot" });

eve 的 span 覆盖:模型调用、工具执行(execute_tool)、审批、子代理委派、durable turn。导出到 Datadog / Honeycomb / Sentry / Jaeger 都是同一个口子。

内容捕获受 audience 和追踪策略控制。 私有会话默认不记录内容。这是合规友好的默认值,别随手改成 public。

4. 线上核对

GET /eve/v1/info 返回编译后的完整快照:工具、技能、连接、通道、沙箱、诊断。适合回答「线上到底加载了什么」。

18.6 安全模型

边界 规则
应用运行时 vs 沙箱 密钥只在应用侧,沙箱没有 process.env
模型 vs 凭据 连接凭据由 eve 注入,模型看不到 URL 和 token
通道 vs 工具 通道鉴权决定谁能发消息,工具权限另算
用户输入 vs 身份 身份只从 auth.current 取,绝不从消息取
记忆 vs 指令 召回内容是不可信数据,要在指令里写明

失败模式一律 fail closed:缺配置、缺凭据、验证失败都拒绝,不回退到宽松策略。

凭据代理:沙箱里的进程需要访问鉴权服务时,由应用侧换 token 再转发,token 不进入沙箱。

密钥怎么进来、在哪一层读、什么时候会被烘进编译产物,见附录 C。

18.7 成本控制

旋钮 作用 代价
系统提示长度 每轮都付 太短行为不稳
技能 deferred 减少常驻描述 多一次搜索调用
工具 deferred / 连接白名单 减少 schema token 模型可能找不到工具
压缩阈值调低 更早摘要 丢细节
limits 上限 硬止损 长任务被中断

真正的省钱不在这些旋钮,而在减少无效循环:工具返回可操作的错误信息、给模型明确的停止条件、不要让它在同一个失败上重试五次。

18.8 上线检查清单

鉴权与访问

工具与副作用

沙箱

上下文与成本

状态与记忆

持久化

通道

评测与发布

可观测性

18.9 上线后最常见的五个问题

  1. 成本突然上涨。 通常是某个工具开始返回巨大结果,或者模型陷入了重试循环。看 step.completed 的 usage 分布。
  2. 会话 409。 部署了新版本,老会话 stranded。检查 eveVersion。
  3. 审批卡住。 通道没渲染按钮,或 turn 服务对象之外的人点了。看 input.requested 和 approval.candidate。
  4. 子代理超时。 父级额度被子代理分光了。看父级 turn.waiting 和子代理的 usage。
  5. 记忆串号。 scope 解析器读了模型输入,或匿名回退到了共享值。审 scope 函数。

18.10 练习

  1. 对照 18.8 的清单,给你的项目逐条打勾,把没过的写成 issue。
  2. 用 defineDynamic 按租户切换模型,验证 eve info 里显示的是解析器而不是具体模型。
  3. 把一个通用能力打成扩展包,在两个 agent 里挂载,确认命名空间前缀生效。
  4. 配一个 OTel 出口(本地 Jaeger 就行),看一次完整 turn 的 span 树。
  5. 故意把一个会话的 scope 写成固定字符串,用两个用户验证记忆串号,然后修掉。

延伸阅读


上一章:17 前端与部署 · 附录:A 术语与速查