Skip to content
Embabel 文档版本 0.3.0-SNAPSHOT 源快照 3727d449 更新于 2026-09-29

10 · 注解(Annotations)

注解模型(Annotation model)

Embabel 提供了一套 Spring 风格的注解模型,用来定义 Agent、动作、目标与条件。 这是在 Java 中推荐使用的模型,在 Kotlin 中同样有吸引力。

@Agent 注解

该注解用在类上,用来定义一个 Agent。 它是一个 Spring 构造型(stereotype)注解,因此会触发 Spring 组件扫描 —— 你的 Agent 类会被自动注册为 Spring bean, 同时也会注册到 Agent 框架中,从而可以在 Agent 进程里使用。

你必须提供 description 参数,它是 Agent 的人类可读描述。 这一点尤其重要,因为它可能被 LLM 用于 Agent 选择。

@EmbabelComponent 注解

该注解用在类上,表示这个类暴露了可供 Agent 使用的动作、目标与条件,但它本身不是一个 Agent。 它是 Spring 构造型注解,会触发组件扫描;你的 Embabel 组件会被自动注册为 Spring bean, 并注册到 Agent 框架中,使它提供的动作、目标与条件可以在 Agent 进程里使用。

Embabel 组件与 Utility AI 规划器配合使用时最有价值 —— 后者会从所有可用动作中选出下一个最有价值的动作。

@Action 注解

@Action 注解用来标记在 Agent 中执行动作的方法。

可以在注解上指定动作的元数据:

  • description:动作的人类可读描述。
  • pre:除输入类型之外,动作执行前必须满足的额外前置条件列表。
  • post:除输出类型之外,动作执行后可能满足的额外后置条件列表。
  • canRerun:布尔值,表示动作在已经执行过之后是否可以重跑。默认 false。
  • readOnly:布尔值,表示该动作没有外部副作用。只读动作仅分析数据、产出派生对象, 不修改外部系统(API、数据库、文件等)。这在学习/追赶(learning/catchup)模式下有用 —— 你希望摄入并理解数据,但不触发任何变更。默认 false。
  • clearBlackboard:布尔值,表示该动作完成后是否清空黑板。 为 true 时,黑板上所有对象都会被移除(该动作的输出除外)。这适合在多步工作流中重置上下文, 也可以通过丢弃不再需要的对象来让流程的持久化更高效。默认 false。
  • cost:动作的相对成本,0 ~ 1。默认 0.0。
  • value:执行该动作的相对价值,0 ~ 1。默认 0.0。

清空黑板(Clearing the Blackboard)

clearBlackboard 属性在两种场景下有用:

  • 多步工作流:你想重置处理上下文;
  • 循环状态:某个动作返回到之前访问过的状态类型。

当一个 clearBlackboard = true 的动作完成时,黑板上除该动作输出以外的所有对象都会被移除。 这可以防止累积的中间数据影响后续处理,并使循环成为可能。

循环状态(Looping States)

clearBlackboard 最常见的用途是在基于状态的工作流中实现循环:

kotlin
@State
record ProcessingState(String data, int iteration) {
    @Action(clearBlackboard = true) // ①
    LoopOutcome process() {
        if (iteration >= 3) {
            return new DoneState(data);
        }
        return new ProcessingState(data + "+", iteration + 1); // ②
    }
}

① clearBlackboard = true 让"返回同一个状态类型"成为可能 ② 如果不清理,返回 ProcessingState 会被阻止,因为该类型已经存在于黑板上

关于循环状态模式的更多细节,见「使用状态(Using States)」。

重置上下文(Resetting Context)

也可以用 clearBlackboard 来重置多步工作流中的上下文:

java
@Agent(description = "Multi-step document processing")
public class DocumentProcessor {

    @Action(clearBlackboard = true) // ①
    public ProcessedDocument preprocess(RawDocument doc) {
        return new ProcessedDocument(doc.getContent().trim());
    }

    @AchievesGoal(description = "Produce final output")
    @Action
    public FinalOutput transform(ProcessedDocument doc) { // ②
        return new FinalOutput(doc.getContent().toUpperCase());
    }
}

① preprocess 完成后黑板被清空,只剩下 ProcessedDocument,原始的 RawDocument 被移除。 ② transform 动作只会收到 ProcessedDocument,不会收到任何更早的输入。

⚠️ 不要在达成目标的动作(带 @AchievesGoal)上使用 clearBlackboard。 清空黑板会移除 hasRun 追踪条件,可能干扰目标达成。请把 clearBlackboard 用在中间动作上。

用 @Cost 动态计算成本(Dynamic Cost Computation with @Cost)

@Action 上的 cost 与 value 字段只能指定静态值;你也可以用 @Cost 注解在规划期动态计算这些值。 当动作的成本取决于黑板当前状态时,这就很有用。

@Cost 注解标记一个返回成本值(0.0 ~ 1.0 之间的 double)的方法。 然后你在 @Action 注解里通过 costMethod 或 valueMethod 引用该方法。

java
@Agent(description = "Processor with dynamic cost")
public class DataProcessor {

    @Cost(name = "processingCost") // ①
    public double computeProcessingCost(@Nullable LargeDataSet data) { // ②
        if (data != null && data.size() > 1000) {
            return 0.9; // 大数据集成本高
        }
        return 0.1; // 小数据集或数据集缺失时成本低
    }

    @Action(costMethod = "processingCost") // ③
    public ProcessedData process(RawData input) {
        return new ProcessedData(input.transform());
    }
}

注记:

  • ① @Cost 注解标记一个方法用于动态成本计算,name 参数用来标识这个成本方法;
  • ② @Cost 方法中的领域对象参数必须可空。如果对象不在黑板上,会传入 null;
  • ③ costMethod 字段按名称引用该 @Cost 方法。

与 @Condition 方法的关键差异:

  • @Cost 方法中所有领域对象参数必须可空(Java 用 @Nullable,Kotlin 用 ?);
  • 当某个领域对象不在黑板上时,传入 null,而不是让方法失败;
  • 方法必须返回 0.0 ~ 1.0 之间的 double;
  • 可以把 Blackboard 作为参数传入,直接访问全部可用对象。

你也可以用 valueMethod 计算动态价值:

java
@Agent(description = "Agent with dynamic value computation")
public class PrioritizedAgent {

    @Cost(name = "urgencyValue")
    public double computeUrgency(@Nullable Task task) {
        if (task == null) {
            return 0.5;
        }
        if (task.getPriority() == Priority.HIGH) {
            return 1.0;
        }
        if (task.getPriority() == Priority.MEDIUM) {
            return 0.6;
        }
        return 0.2;
    }

    @AchievesGoal(description = "Process high-priority tasks")
    @Action(valueMethod = "urgencyValue")
    public Result processTask(Task task) {
        return new Result(String.format("Processed: %s", task.getName()));
    }
}

@Cost 方法在规划期、动作执行之前被调用。它让规划器能够基于运行时状态,对"优先选哪个动作"做出有依据的决策。

动态成本与 Utility 规划(PlannerType.UTILITY)配合尤其有用 —— 在 utility 规划中,成本/价值权衡是核心概念。 utility 规划器依据**净价值(value − cost)**评估动作,因此动态成本计算对精细化决策至关重要。

@Condition 注解

@Condition 注解用来标记评估条件的方法。 它们可以接收 OperationContext 参数,以访问黑板及其它基础设施。 如果它们接收领域对象参数,那么在有合适的实例可用之前,该条件会自动为 false。

条件方法不应有副作用(例如写黑板)。这很重要,因为它们可能被多次调用。

用 SpEL 表达动态条件(Dynamic Conditions with SpEL)

除了使用 @Condition 方法,你还可以用 Spring 表达式语言(SpEL) 直接在 @Action 注解上指定动态前置条件。 这些表达式针对黑板求值,让你不必另写条件方法就能基于运行时状态构造条件。

表达式语言是可插拔的,但目前 SpEL 是唯一受支持的实现。完整语法细节见 Spring 的 SpEL 官方文档。

SpEL 条件写在 pre 数组里,带 spel: 前缀:

java
@Action(
        pre = {"spel:assessment.urgency > 0.5"} // ①
)
public void handleUrgentIssue(Issue issue, IssueAssessment assessment) {
    // 只有紧急度超过 0.5 时该动作才运行
}

① spel: 前缀表示这是一个针对黑板求值的 SpEL 表达式。

表达式语法

SpEL 表达式通过绑定名引用黑板对象(通常是类名的 camelCase 形式)。表达式必须求值为布尔值。

java
@Agent(description = "Issue triage agent")
public class IssueTriageAgent {

    @Action(
            pre = {"spel:issueAssessment.urgency > 0.0"} // ①
    )
    public void escalateUrgentIssue(
            GHIssue issue,
            IssueAssessment issueAssessment
    ) {
        logger.info("Escalating urgent issue #{}", issue.getNumber());
    }

    @Action(
            pre = {"spel:ghIssue instanceof T(org.kohsuke.github.GHPullRequest) && ghIssue.changedFiles > 10"} // ②
    )
    public void reviewLargePullRequest(
            GHPullRequest issue,
            PullRequestAssessment assessment
    ) {
        logger.info("Large PR detected: #{} with {} files changed",
                issue.getNumber(), issue.getChangedFiles());
    }
}

① 简单的属性比较:仅当 urgency 属性大于 0.0 时动作才触发。 ② 类型检查 + 属性访问:仅当 pull request 的变更文件数超过 10 时动作才触发;T() 运算符用于 instanceof 检查中的 Java 类型引用。

集合过滤(Collection Filtering)

SpEL 的集合选择语法(?[])在检查黑板上集合的条件时很有用:

java
@Action(
        pre = {
                "spel:newEntity.newEntities.?[#this instanceof T(com.example.domain.Issue) " +
                        "&& !(#this instanceof T(com.example.domain.PullRequest))].size() > 0" // ①
        }
)
public IssueAssessment reactToNewIssue(
        GHIssue ghIssue,
        NewEntity<?> newEntity,
        Ai ai
) {
    // 仅当 newEntities 中包含 Issue 但不是 PullRequest 时触发
    return ai.withDefaultLlm() // 该示例使用 claude-sonnet-4
            .creating(IssueAssessment.class)
            .fromTemplate("issue_triage", Map.of("issue", ghIssue));
}

@Action(
        pre = {
                "spel:newEntity.newEntities.?[#this instanceof T(com.example.domain.PullRequest)].size() > 0" // ②
        }
)
public PullRequestAssessment reactToNewPullRequest(
        GHPullRequest pr,
        NewEntity<?> newEntity,
        Ai ai
) {
    // 仅当 newEntities 中包含 PullRequest 时触发
    return ai.withDefaultLlm() // 该示例使用 claude-sonnet-4
            .creating(PullRequestAssessment.class)
            .fromTemplate("pr_triage", Map.of("pr", pr));
}

① ?[] 运算符用于过滤集合,#this 指代每个元素。该表达式检查"至少有一个元素是 Issue 但不是 PullRequest"。 ② 更简单的过滤:检查是否存在 PullRequest 实例。

常见 SpEL 模式

模式说明
spel:obj.property > value简单的属性比较
spel:obj instanceof T(com.example.Type)用全限定类名做类型检查
spel:collection.size() > 0检查集合非空
spel:collection.?[condition].size() > 0检查过滤后的集合有元素
spel:obj.property != null空值检查
spel:condition1 && condition2用 AND 组合条件
spel:condition1 || condition2用 OR 组合条件

简单的属性检查与类型判别,用 SpEL 条件;复杂逻辑、或需要在多个动作间复用的条件,优先用 @Condition 方法。对于"只要某个特定类型被加入黑板就触发动作"的响应式场景,考虑改用 trigger 字段 —— 它比写 SpEL 表达式更简单。

黑板绑定名默认由类名转成 camelCase 得到。 你也可以通过动作上的 outputBinding,或用特定名称把对象加入黑板,来显式指定绑定名。

Action 与 Condition 方法都可以从父类继承 —— 也就是说,父类上带注解的方法会被当作子类实例上的动作。 请给你的 Action 与 Condition 方法取唯一的名字,以便规划器能区分它们。

参数(Parameters)

@Action 方法必须至少有一个参数。 @Condition 方法可以有零个或多个参数,除此之外在参数规则上与 @Action 方法相同。 参数顺序不重要。

参数分两类:

  • 领域对象(Domain objects):动作方法的常规输入。它们由黑板支撑,会作为动作方法的输入使用。 可空的领域对象参数:如果它在黑板上非空,就会被填充 —— 这实现了"锦上添花但非必需"的参数。 Kotlin 用 ? 声明可空参数;Java 用 org.springframework.lang.Nullable 或其它 Nullable 注解标记。
  • 基础设施参数(Infrastructure parameters):例如 OperationContext、ProcessContext、Ai,可用在动作或条件方法中。

领域对象驱动规划,它们指定了动作的前置条件。

动作方法里可以使用 ActionContext 或它的子类型 ExecutingOperationContext, 它新增了 asSubProcess 方法,用来在子进程中运行其它 Agent —— 这是组合的重要一环。

参数请尽量使用最不具体的类型:除非你要创建子进程,否则用 OperationContext。

自定义参数(Custom Parameters)

除了上述两类默认参数,你还可以通过实现 ActionMethodArgumentResolver 接口来提供自定义参数。 该接口的两个主要方法是:

  • supportsParameter:指明支持哪类参数;
  • resolveArgument:把参数解析成用于调用动作方法的对象。

注意它与 Spring MVC 的相似性 —— 在 Spring MVC 中你可以通过实现 HandlerMethodArgumentResolver 提供自定义参数。 所有默认参数也都是由 ActionMethodArgumentResolver 实现提供的。

要注册你的自定义参数解析器,请在 Spring 配置中把它提供给 DefaultActionMethodManager 组件。 通常你会把(部分)默认解析器连同你的自定义解析器一起注册,以支持默认参数。

请确保把 BlackboardArgumentResolver 注册为最后一个解析器,以保证其它解析器优先。

@Provided 注解

@Provided 注解把动作方法的某个参数标记为由平台上下文(例如 Spring 的 ApplicationContext)提供, 而不是从黑板解析。

它尤其适用于:

  • 在 @State 类内部访问外层组件(@State 类必须是 static 嵌套类或顶层类);
  • 注入那些不是领域对象、但处理过程中需要的服务;
  • 访问配置或其它由平台管理的 bean。
java
@EmbabelComponent
public class ReservationFlow {

    private final BookingService bookingService;
    private final NotificationService notificationService;

    public ReservationFlow(BookingService bookingService, NotificationService notificationService) {
        this.bookingService = bookingService;
        this.notificationService = notificationService;
    }

    @Action
    public CollectDetails start(UserRequest request) {
        return new CollectDetails(request.customerId());
    }

    @State
    public record CollectDetails(String customerId) {

        @Action
        public ConfirmReservation confirm(
                ReservationDetails details,  // ①
                @Provided ReservationFlow flow // ②
        ) {
            var booking = flow.bookingService.reserve(details);
            flow.notificationService.sendConfirmation(booking);
            return new ConfirmReservation(booking);
        }
    }

    @State
    public record ConfirmReservation(Booking booking) {
        @AchievesGoal(description = "Reservation completed")
        @Action
        public BookingResult complete() {
            return new BookingResult(booking);
        }
    }
}

① ReservationDetails 是从黑板解析的领域对象。 ② ReservationFlow 通过 @Provided 从 Spring 上下文注入 —— 由此获得外层组件中的服务。

工作原理

当 Spring 可用时,SpringContextProvider 会从 ApplicationContext 查找 bean 来解析 @Provided 参数。 参数类型必须与上下文中的某个 bean 匹配。

java
@State
public record ProcessingState(String data) {

    @Action
    public NextState process(
            @Provided MyService myService, // ①
            @Provided AppConfig config     // ②
    ) {
        var result = myService.process(data, config.getSetting());
        return new NextState(result);
    }
}

① 任何 Spring bean 都可以用 @Provided 注入。 ② 一个方法里可以有多个 @Provided 参数。

何时使用 @Provided

当你需要访问以下内容时使用它:

  • 从 @State 动作访问外层的 @EmbabelComponent 或 @Agent 类;
  • 属于基础设施关注点、而非领域对象的服务;
  • 配置或环境值。

不要用 @Provided 处理:

  • 应当驱动规划的领域对象(请改用普通参数);
  • 需要在黑板上被追踪的对象。

由于 @State 类必须是静态嵌套类或顶层类,@Provided 是访问外层组件服务的推荐方式 —— 它让状态类保持可序列化,同时仍能拿到依赖。

@Provided 参数在黑板参数之前解析。 如果某个类型两个来源都能提供,@Provided 优先。

按名称绑定(Binding by name)

@RequireNameMatch 注解可用于按名称绑定参数。

用 trigger 实现响应式触发(Reactive triggers with trigger)

@Action 注解上的 trigger 字段可以启用响应式行为:只有当某个特定类型是最近加入黑板的值时,动作才会触发。 这在事件驱动场景中很有用 —— 你希望针对某个特定事件做出反应,即使黑板上同时存在多种类型的多个参数。

例如在聊天系统里,你可能希望动作仅在新用户消息到来时触发,而不是在其它上下文更新时触发:

java
@Agent(description = "Chat message handler")
public class ChatAgent {

    @AchievesGoal(description = "Respond to user message")
    @Action(trigger = UserMessage.class) // ①
    public Response handleMessage(
            UserMessage message,
            Conversation conversation // ②
    ) {
        return new Response("Received: " + message.content());
    }
}

① trigger 字段表示:只有 UserMessage 是最近加入黑板的结果时,该动作才触发。 ② Conversation 也必须可用,但不要求它是触发事件。

没有 trigger 时,只要所有参数在黑板上可用,动作就会触发。 有了 trigger,指定的类型还必须同时是最近加入的值。

这在以下情况尤其有用:

  • 你有多个动作分别处理不同的事件类型;
  • 你想区分"数据已可用"与"事件刚刚发生";
  • 你在构建事件驱动或响应式工作流。
java
@Agent(description = "Multi-event processor")
public class EventProcessor {

    @Action(trigger = EventA.class) // ①
    public Result handleEventA(EventA eventA, EventB eventB) {
        return new Result("Triggered by A");
    }

    @AchievesGoal(description = "Handle event B")
    @Action(trigger = EventB.class) // ②
    public Result handleEventB(EventA eventA, EventB eventB) {
        return new Result("Triggered by B");
    }
}

① handleEventA 在 EventA 被加入(且 EventB 可用)时触发。 ② handleEventB 在 EventB 被加入(且 EventA 可用)时触发。

trigger 字段检查的是:指定类型是否匹配黑板上的 lastResult()。 最近的结果是通过任意绑定操作最后加入的那个对象。

返回类型的处理(Handling of return types)

动作方法通常返回单个领域对象。

允许可空返回类型。返回 null 会触发重规划。 从那一点起可能(也可能不)存在替代路径, 但那条路径不会是规划器原先试图达成的目标。

有一种特殊情况:返回类型本质上可以是联合类型(union type) —— 动作方法可以返回若干类型中的一个或多个。 这是通过让返回类型实现 SomeOf 标记接口来实现的。 该接口的实现可以有多个可空字段,其中任何非空值都会被绑定到黑板, 而该动作的后置条件会包含返回类型的所有可能字段。

例如:

java
// 必须实现 SomeOf 接口
public record FrogOrDog(
        @Nullable Frog frog,
        @Nullable Dog dog
) implements SomeOf {}

@Agent(description = "Illustrates use of the SomeOf interface")
public class ReturnsFrogOrDog {

    @Action
    public FrogOrDog frogOrDog() {
        return new FrogOrDog(new Frog("Kermit"), null);
    }

    // 这能工作,是因为返回类型的 frog 字段被设置了
    @AchievesGoal(description = "Create a prince from a frog")
    @Action
    public PersonWithReverseTool toPerson(Frog frog) {
        return new PersonWithReverseTool(frog.name());
    }
}

这能让路由场景以优雅的方式实现。

SomeOf 实例的多个字段可以同时非空,这不是错误 —— 它可能带来最恰当的路由。

路由也可以通过子类型实现,如下例:

java
@Action
public Intent classifyIntent(UserInput userInput) { // ①
    return switch (userInput.content()) {
        case "billing" -> new BillingIntent();
        case "sales" -> new SalesIntent();
        case "service" -> new ServiceIntent();
        default -> {
            logger.warn("Unknown intent: {}", userInput);
            yield null;
        }
    };
}

@Action
public IntentClassificationSuccess billingAction(BillingIntent intent) { // ②
    return new IntentClassificationSuccess("billing");
}

@Action
public IntentClassificationSuccess salesAction(SalesIntent intent) {
    return new IntentClassificationSuccess("sales");
}

// ...

① 分类动作返回父类型 Intent。真实分类大概率会使用 LLM。 ② billingAction 等动作接收 Intent 的子类型,因此只有当分类动作返回了该子类型时才会被调用。

动作方法的实现(Action method implementation)

Embabel 让你能使用常见类型,把 LLM 调用与应用代码无缝整合。 @Action 方法就是普通方法,可以使用你喜欢的任何库或框架。

它唯一特殊之处,是能够使用 OperationContext 参数来访问黑板并调用 LLM。

@AchievesGoal 注解

@AchievesGoal 注解可以加在 @Action 方法上,表示该动作的完成即达成某个特定目标。

@SecureAgentTool 注解

@SecureAgentTool 为一个被暴露为远程 MCP 工具的 Embabel @Action 方法或 @Agent 类声明安全契约。 它接收一个 Spring Security 的 SpEL 表达式,在工具被调用时、Embabel 的 GOAP 规划器执行动作体之前, 针对当前的 Authentication 求值。

放置位置(Placement)

@SecureAgentTool 可以放在 @Agent 类上,统一保护每个 @Action;也可以放在单个方法上做更细粒度的控制。 两者同时存在时,方法级注解优先于类级注解。

类级 —— 一个注解保护该 Agent 中的所有动作,包括在达成目标的动作之前运行的中间步骤:

kotlin
@Agent(description = "Research a topic and return a news digest")
@SecureAgentTool("hasAuthority('news:read')") // ①
class NewsDigestAgent {

    @Action
    fun extractTopic(userInput: UserInput, context: OperationContext): NewsTopic { ... } // ②

    @AchievesGoal(description = "Produce a curated news digest",
        export = Export(remote = true, name = "newsDigest",
            startingInputTypes = [UserInput::class]))
    @Action
    fun produceDigest(topic: NewsTopic, context: OperationContext): NewsDigest { ... } // ②
}

① 类上的一个注解保护该 Agent 中的每个 @Action。 ② extractTopic 与 produceDigest 都要求 news:read。 没有类级保护时,像 extractTopic 这样的中间动作会在"达成目标动作的安全检查"触发之前自由运行。

方法级覆盖 —— 方法级注解优先于类级表达式,因此可以让某个动作要求更高的权限:

kotlin
@Agent(description = "Market intelligence agent")
@SecureAgentTool("hasAuthority('market:read')") // ①
class MarketIntelligenceAgent {

    @Action
    fun gatherIntelligence(subject: AnalysisSubject, context: OperationContext): String { ... }

    @SecureAgentTool("hasAuthority('market:admin')") // ②
    @AchievesGoal(description = "Produce market report")
    @Action
    fun synthesiseReport(
        subject: AnalysisSubject,
        rawIntelligence: String,
        context: OperationContext
    ): MarketIntelligenceReport { ... }
}

① 所有动作默认要求 market:read。 ② synthesiseReport 要求 market:admin —— 方法级注解覆盖了类级注解。

支持的表达式(Supported expressions)

任何 Spring Security 的 SpEL 表达式都合法:

表达式含义
hasAuthority('finance:read')主体必须携带该精确权限
hasAnyAuthority('finance:read', 'finance:admin')主体必须携带所列权限中的至少一个
hasRole('ADMIN')主体必须携带 ROLE_ADMIN(ROLE_ 前缀会自动添加)
isAuthenticated()任意已认证主体,不限定权限
hasAuthority('payments:write') and #request.amount > 10000组合权限与请求参数的条件

配置(Setup)

把 MCP 安全 starter 加入你的 pom.xml:

xml
<dependency>
    <groupId>com.embabel.agent</groupId>
    <artifactId>embabel-agent-starter-mcpserver-security</artifactId>
    <version>${embabel-agent.version}</version>
</dependency>

该 starter 会自动配置 SecureAgentToolAspect 以及所需的 Spring Security MethodSecurityExpressionHandler。 不需要额外的 @EnableMethodSecurity 注解。

@SecureAgentTool 是方法级的安全控制,不是 HTTP 级的。

生产使用时,请把它与一个校验 JWT Bearer token 的 SecurityFilterChain 组合起来, 使未认证请求在到达 GOAP 规划器之前就被拒绝。 通用配置见 Spring Security JWT Resource Server 文档;MCP 专属示例见 MCP Security。

实现 StuckHandler 接口

如果带注解的 Agent 类实现了 StuckHandler 接口,它就能处理"动作自己卡住"的情况。 例如,它可以向黑板添加数据。

示例:

java
@Agent(description = "self unsticking agent")
public class SelfUnstickingAgent implements StuckHandler {

    private boolean called = false;

    // 因为没有 dog 可以转成 frog,该 Agent 会卡住
    @Action
    @AchievesGoal(description = "the big goal in the sky")
    public Frog toFrog(Dog dog) {
        return new Frog(dog.name());
    }

    // Agent 卡住时,这个方法会被调用
    @Override
    public StuckHandlerResult handleStuck(AgentProcess agentProcess) {
        called = true;
        agentProcess.addObject(new Dog("Duke"));
        return new StuckHandlerResult(
                "Unsticking myself",
                this,
                StuckHandlingResultCode.REPLAN,
                agentProcess
        );
    }
}

进阶用法:嵌套进程(Nested processes)

一个 @Action 方法可以调用另一个 Agent 进程。这通常用于复用那些用 DSL 组合出来的标准流程。

使用 ActionContext.asSubProcess 方法从动作上下文创建子进程。例如:

java
@Action
public ScoredResult<Report, SimpleFeedback> report(
        ReportRequest reportRequest,
        ActionContext context
) {
    return context.asSubProcess(
            // 会创建一个强类型的 Agent 子进程
            EvaluatorOptimizer.generateUntilAcceptable(
                    5,
                    ctx -> ctx.promptRunner()
                            .withToolGroup(CoreToolGroups.WEB)
                            .create(String.format("""
                                            Given the topic, generate a detailed report in %d words.

                                            # Topic
                                            %s

                                            # Feedback
                                            %s
                                            """,
                                    reportRequest.words(),
                                    reportRequest.topic(),
                                    ctx.getInput() != null ? ctx.getInput() : "No feedback provided")),
                    ctx -> ctx.promptRunner()
                            .withToolGroup(CoreToolGroups.WEB)
                            .create(String.format("""
                                            Given the topic and word count, evaluate the report and provide feedback
                                            Feedback must be a score between 0 and 1, where 1 is perfect.

                                            # Report
                                            %s

                                            # Report request:
                                            %s
                                            Word count: %d
                                            """,
                                    ctx.getInput().report(),
                                    reportRequest.topic(),
                                    reportRequest.words()))
            ));
}

用 RunSubagent 运行子 Agent

RunSubagent 工具提供了一种便捷方式,让你在 @Action 方法内部运行嵌套 Agent,而无需直接访问 ActionContext。 当你想把工作委派给另一个带 @Agent 注解的类、或某个 Agent 实例时,它特别有用。

运行带 @Agent 注解的实例

当你持有某个带 @Agent 注解的类的实例时,用 RunSubagent.fromAnnotatedInstance():

带注解的实例可以被 Spring 注入到你的 Agent 类中。 由于 @Agent 是 Spring 构造型注解,你可以把一个 Agent 注入另一个 Agent,并把它作为子 Agent 运行 —— 这带来了清晰的关注点分离,同时保持可测试性。

java
@Agent(description = "Outer agent that delegates to an injected subagent")
public class OuterAgent {

    private final InnerSubAgent innerSubAgent;

    public OuterAgent(InnerSubAgent innerSubAgent) { // ①
        this.innerSubAgent = innerSubAgent;
    }

    @Action
    public TaskOutput start(UserInput input) {
        return RunSubagent.fromAnnotatedInstance(
                innerSubAgent, // ②
                TaskOutput.class
        );
    }

    @Action
    @AchievesGoal(description = "Processing complete")
    public TaskOutput done(TaskOutput output) {
        return output;
    }
}

@Agent(description = "Inner subagent that processes input")
public class InnerSubAgent {

    @Action
    public Intermediate stepOne(UserInput input) {
        return new Intermediate(input.getContent());
    }

    @Action
    @AchievesGoal(description = "Subagent complete")
    public TaskOutput stepTwo(Intermediate data) {
        return new TaskOutput(data.value().toUpperCase());
    }
}

① Spring 通过构造器注入把 InnerSubAgent bean 注入进来。 ② 被注入的实例传给 RunSubagent.fromAnnotatedInstance()。

在 Kotlin 中,你可以使用 reified 版本,语法更简洁。

运行 Agent 实例

当你已经持有 Agent 对象时(例如通过编程方式创建、或用 AgentMetadataReader 得到),用 RunSubagent.instance():

java
@Agent(description = "Outer agent with Agent instance")
public class OuterAgentWithAgentInstance {

    @Action
    public TaskOutput start(UserInput input) {
        Agent agent = (Agent) new AgentMetadataReader()
                .createAgentMetadata(new InnerSubAgent());
        return RunSubagent.instance(agent, TaskOutput.class);
    }

    @Action
    @AchievesGoal(description = "Processing complete")
    public TaskOutput done(TaskOutput output) {
        return output;
    }
}

工作原理(How It Works)

RunSubagent 的方法会抛出 SubagentExecutionRequest 异常,由框架捕获。 随后框架把子 Agent 作为当前 Agent 进程内的子进程执行,共享同一个黑板上下文。 子 Agent 那个达成目标动作的结果会被返回给调用方动作。

这种做法有几项优势:

  • 更简洁的语法:不需要把 ActionContext 传进动作方法;
  • 类型安全:返回类型在编译期就被强制;
  • 可组合:轻松用更简单的 Agent 组合出复杂工作流;
  • 可复用:同一个子 Agent 可以在多个上下文中使用。

与 ActionContext.asSubProcess 的对比

RunSubagent 与 ActionContext.asSubProcess 达成同样的结果,只是风格不同:

方式何时使用示例
RunSubagent.fromAnnotatedInstance()当你持有带 @Agent 注解的实例,且不需要 ActionContext 时RunSubagent.fromAnnotatedInstance(new SubAgent(), Result.class)
RunSubagent.instance()当你持有 Agent 对象时RunSubagent.instance(agent, Result.class)
ActionContext.asSubProcess()当你还需要 ActionContext 做其它操作时context.asSubProcess(Result.class, agent)

只有当动作方法只需要委派给子 Agent 时,才用 RunSubagent; 当你还需要额外的上下文操作时,用 ActionContext.asSubProcess()。

动作异常处理(Action Exception Handling)

Action 内部的异常处理由重试策略(Retry Policy) 支配。

除 TransientAiException 之外,下列所有异常都被视为不可重试。 更准确地说,策略按以下顺序对不可重试异常分类:

  • ReplanRequestedException
  • TerminateActionException
  • TerminateAgentException
  • ToolControlFlowSignal
  • NonTransientAiException
  • IllegalArgumentException
  • IllegalStateException
  • UnsupportedOperationException
  • ClassCastException

如果某个异常不属于上述任何一类,它会被映射为可重试异常。

框架允许你创建自定义的可重试 / 不可重试异常,从而让开发者完全掌控 Action 的重试行为。

Embabel 提供两种定义自定义可重试与不可重试异常的方式:

  • 继承 ActionException —— 便捷的基类,内置重试分类;
  • 实现标记接口(marker interfaces) —— 对既有异常层次结构提供最大灵活性。

方式 1:继承 ActionException

推荐做法是:可重试失败继承 ActionException.Transient,不可重试失败继承 ActionException.Permanent:

kotlin
import com.embabel.agent.core.ActionException

// 暂时性失败 —— 会被重试
class ApiTimeoutException(message: String, cause: Throwable? = null)
    : ActionException.Transient(message, cause)

// 永久性失败 —— 不会被重试
class ValidationException(message: String, cause: Throwable? = null)
    : ActionException.Permanent(message, cause)

方式 2:实现标记接口

对于既有的异常层次结构,或需要更多控制时,直接实现 Retryable 或 NonRetryable 标记接口:

kotlin
import com.embabel.agent.core.Retryable
import com.embabel.agent.core.NonRetryable

// 暂时性失败 —— 会被重试
class NetworkException(message: String, cause: Throwable? = null)
    : RuntimeException(message, cause), Retryable

// 永久性失败 —— 不会被重试
class InvalidOrderException(message: String)
    : RuntimeException(message), NonRetryable

常见用例

暂时性失败(用 ActionException.Transient 或 Retryable):

  • 网络超时
  • 限流(429 错误)
  • 资源临时不可用
  • 连接失败
  • 数据库死锁

永久性失败(用 ActionException.Permanent 或 NonRetryable):

  • 校验错误
  • 违反业务规则
  • 参数非法
  • 资源未找到(404 错误)
  • 认证失败(401 错误)
  • 授权失败(403 错误)

BytesRouter 是社区翻译站点,所有译文仅供参考,一切以官方英文原文为准。