导读:当 Agent 从 Demo 走向生产,你会发现 ReAct 模式并非万能——复杂任务容易"绕圈子",金额计算不能交给大模型拍脑袋,线上死循环一晚上就能烧光 Token 预算。本文将围绕四个核心主题展开:Plan-and-Execute 分步规划确定性流程与 AI 决策的边界划分生产级可靠性工程实践,以及一个真实的 智能销售数据分析 Agent 项目全貌。读完之后,你不仅能写出更聪明的 Agent,还能让它在生产环境里"稳如老狗"。


一、Plan-and-Execute:让 Agent 先想清楚再动手

1.1 ReAct 模式的天花板

我们之前学过 ReAct(Reasoning + Acting)模式:思考 -> 执行 -> 观察 -> 再思考……循环往复直到得到答案。对于三五步就能搞定的简单任务,ReAct 非常顺手。

但如果任务变成这样:

"帮我分析上个月各区域的销售数据,找出下滑超过 20% 的区域,再查一下这些区域竞品的活动,最后生成一份分析报告。"

ReAct 就有点力不从心了。因为它是一种边想边做的模式,每一步只看当前状态来决定下一步,缺少全局视图。碰到步骤多、有依赖关系的复杂任务,它很容易原地打转、反复绕圈。

1.2 Plan-and-Execute 的核心思路

Plan-and-Execute 的哲学很简单:先把整个任务想明白,拆解成有序的步骤,然后逐步执行

你在用 ChatGPT、Claude 等工具时,可能注意到右上角会有一个 "thinking" 的过程——模型先梳理"用户要什么",然后列出"第一步做什么、第二步做什么、第三步做什么"。这就是 Plan-and-Execute 的体现。

1.3 两种模式对比

维度 ReAct Plan-and-Execute
决策方式 每步基于上一步结果决定下一步 先生成完整计划,再逐步执行
全局视图 无,容易绕圈 有,步骤间依赖关系清晰
动态调整 天然支持 执行中可动态修改后续步骤
适用场景 3~5 步内、路径不确定、探索性任务 5 步以上、步骤有依赖、可并行、需展示进度

经验法则:拿到一个任务,先估算大概需要几步。步骤多就用 Plan-and-Execute;拿不准的先用 ReAct 跑一跑,看效果再决定。

1.4 核心组件:任务规划器(TaskPlanner)

Plan-and-Execute 的核心是一个专门负责任务拆解的 Planner。为什么要单独用一个模型(或单独的 Prompt)来做规划?因为规划和执行对模型的要求差异很大

  • 规划:需要全局视野,理解步骤间的依赖关系
  • 执行:只需专注当前步骤,正确调用工具

分开处理,各自有针对性的 System Prompt,效果会好很多。

来看我们项目中 TaskPlanner 的实现(com.jichi.agent.service.TaskPlanner):

@Service
public class TaskPlanner {

    private final ChatClient plannerClient;

    public TaskPlanner(@Qualifier("dashScopeChatModel") ChatModel chatModel) {
        this.plannerClient = ChatClient.builder(chatModel)
                .defaultSystem("""
                        你是一个任务规划专家。

                        收到用户任务后,把它分解为具体的执行步骤。

                        输出格式(严格遵守):
                        STEP 1: [步骤描述]
                        STEP 2: [步骤描述]
                        STEP 3: [步骤描述]
                        ...

                        要求:
                        - 每步描述要具体,说明做什么、用什么工具、期望结果
                        - 步骤数量合理,不要过于细碎(3-8 步为宜)
                        - 标注步骤依赖(如果某步依赖上一步的结果,说明"依赖 Step X")
                        - 只输出步骤,不要解释
                        """)
                .build();
    }

    public List<String> plan(String task, List<String> availableTools) {
        String toolsDescription = String.join("、", availableTools);

        String planText = plannerClient.prompt()
                .user("可用工具:" + toolsDescription + "\n\n任务:" + task)
                .call()
                .content();

        return parsePlan(planText);
    }

    private List<String> parsePlan(String planText) {
        return Arrays.stream(planText.split("\n"))
                .filter(line -> line.matches("STEP \\d+:.*"))
                .map(line -> line.replaceFirst("STEP \\d+:\\s*", "").strip())
                .toList();
    }
}

几个关键细节

  • 输出格式必须强制约束:如果不规定死 STEP N: 这样的格式,大模型可能输出各种奇怪的格式,parsePlan 解析时就会出问题。
  • 步骤数控制在 3~8 步:让 AI 自由发挥,它可能给出 14 步,但到第 7 步就跑偏了。拆得太碎,模型反而会为了"凑步骤"而瞎搞。
  • 工具列表必须传给规划器plan() 方法接收 availableTools 参数,Planner 只有知道有哪些工具可用,才能合理安排步骤。否则它可能规划出一堆你根本没有的能力。

1.5 核心组件:步骤执行器(StepExecutor)

执行器的核心设计目标是:每次只处理当前步骤,但能看到之前所有步骤的执行结果

为什么要带上下文?举个例子:Step 1 查到了上周销售额 50 万,Step 3 做环比计算时就需要用到这个 50 万。如果执行器每次都从零开始,信息链就断了。

来看 StepExecutorcom.jichi.agent.service.StepExecutor)的实现:

@Service
public class StepExecutor {

    private final ChatClient executorClient;

    public StepExecutor(@Qualifier("dashScopeChatModel") ChatModel chatModel,
                         AssistantTools assistantTools,
                         CustomerServiceTools customerServiceTools) {
        this.executorClient = ChatClient.builder(chatModel)
                .defaultSystem("""
                        你是一个步骤执行器。
                        你会收到一个具体的步骤描述,以及之前步骤的执行结果。
                        请使用合适的工具完成这个步骤,并返回执行结果。
                        如果这个步骤不需要工具,直接用你的知识完成并返回结果。
                        """)
                .defaultTools(assistantTools, customerServiceTools)
                .build();
    }

    /**
     * 执行单个步骤
     *
     * @param step            步骤描述
     * @param previousResults 之前各步骤的执行结果(key: 步骤编号, value: 结果)
     */
    public String execute(String step, Map<Integer, String> previousResults) {
        StringBuilder context = new StringBuilder();

        if (!previousResults.isEmpty()) {
            context.append("之前步骤的执行结果:\n");
            previousResults.forEach((stepNo, result) ->
                    context.append("Step ").append(stepNo).append(" 结果:").append(result).append("\n"));
            context.append("\n");
        }

        context.append("当前需要执行的步骤:").append(step);

        return executorClient.prompt()
                .user(context.toString())
                .call()
                .content();
    }
}

注意两个设计点:一是 Executor 通过 .defaultTools() 挂载了实际的工具类,而 Planner 不挂工具——职责分离;二是 previousResults 使用 Map<Integer, String> 按步骤编号索引,传递给模型的上下文清晰有序。

1.6 控制器:PlanAndExecuteAgent 串联规划与执行

最终 PlanAndExecuteAgentcom.jichi.agent.service.PlanAndExecuteAgent)把 Planner 和 Executor 串起来:

@Service
public class PlanAndExecuteAgent {

    private final TaskPlanner planner;
    private final StepExecutor executor;

    private static final List<String> AVAILABLE_TOOLS = List.of(
            "getWeather(查天气)",
            "getCurrentDateTime(查时间)",
            "getExchangeRate(查汇率)",
            "getOrderTracking(查订单状态)",
            "searchProducts(搜索商品)"
    );

    public ExecutionResult run(String task) {
        log.info("开始执行任务:{}", task);

        List<String> steps = planner.plan(task, AVAILABLE_TOOLS);
        log.info("生成计划,共 {} 步:{}", steps.size(), steps);

        Map<Integer, String> results = new LinkedHashMap<>();

        for (int i = 0; i < steps.size(); i++) {
            String step = steps.get(i);
            int stepNo = i + 1;
            log.info("执行 Step {}:{}", stepNo, step);

            try {
                String result = executor.execute(step, results);
                results.put(stepNo, result);
                log.info("Step {} 完成", stepNo);
            } catch (Exception e) {
                log.error("Step {} 执行失败:{}", stepNo, e.getMessage());
                results.put(stepNo, "执行失败:" + e.getMessage());
                // 这里可以 break(失败就停)或 continue(跳过继续执行后续步骤)
                // 鸡哥建议:非关键步骤 continue,关键步骤 break
            }
        }

        return new ExecutionResult(task, steps, results);
    }

    public record ExecutionResult(
            String originalTask,
            List<String> plan,
            Map<Integer, String> stepResults
    ) {
        public String summary() {
            StringBuilder sb = new StringBuilder();
            sb.append("任务:").append(originalTask).append("\n\n");
            sb.append("执行步骤:\n");
            for (int i = 0; i < plan.size(); i++) {
                int stepNo = i + 1;
                sb.append("Step ").append(stepNo).append(":").append(plan.get(i)).append("\n");
                sb.append("结果:").append(stepResults.getOrDefault(stepNo, "未执行")).append("\n\n");
            }
            return sb.toString();
        }
    }
}

这里用 LinkedHashMap 是有讲究的——它能保证步骤结果按插入顺序排列,传递给模型的上下文更加清晰。另一个额外的好处是:Plan 生成后可以快速返回给前端做进度条展示,用户能清楚看到"当前执行到第几步了",体验非常好。

1.7 避坑提醒

简单任务别往 Plan-and-Execute 里塞。两三步就能搞定的事情,多了一个规划环节反而更慢,还可能把简单任务过度复杂化。


二、确定性流程与 AI 决策:划清边界

2.1 一个真实的教训

曾经有一个团队在早期实践时,把退款审批全权交给了 AI。结果 AI 发现用户描述得特别可怜时,就开始批退款——原本不该批的退款也批了,直接损失两千多块。初期可以理解为经验不足,但如果持续这么做,那就是生产事故

核心原则:涉及金钱、权限或不可逆操作的节点,必须用确定性代码控制。AI 只在理解自然语言、做判断分类、生成内容时介入。

2.2 清晰的责任划分

确定性代码负责 AI 负责
权限校验 理解用户意图
金额计算(扣款、折扣、税率) 内容分类(产品问题/物流问题/服务问题)
状态流转(待支付 -> 已支付) 生成文字回复
不可逆操作(删除数据、发短信) 主观判断、模糊规则
高精度判断 需要理解上下文的决策

简单总结:规则明确、结果唯一的,用代码;规则模糊、需要理解上下文的,用 AI。

比如"退款期限是 7 天"——这个规则非常明确,结果只有"符合"或"不符合",就该用代码判断。不要让 AI 因为用户"装可怜"就网开一面。

2.3 状态机思维:设计混合工作流

在设计复杂的 Agent 流程时,推荐使用状态机来明确每个节点的属性——是 AI 节点还是确定性节点。我们项目里用枚举来定义工作流状态(com.jichi.agent.model.WorkflowState):

public enum WorkflowState {
    START,
    INTENT_CLASSIFICATION,   // AI 节点:理解用户意图
    AUTH_CHECK,              // 确定性节点:权限校验
    CONDITION_CHECK,         // 确定性节点:业务规则判断(7 天期限等)
    AMOUNT_CALCULATION,      // 确定性节点:金额计算
    EXECUTION,               // 确定性节点:执行操作(扣款/退款)
    AI_GENERATION,           // AI 节点:生成回复内容
    NOTIFICATION,            // 确定性节点:发送通知
    HUMAN_REVIEW,            // 人工节点:大额或风险操作等待人工审批
    COMPLETED,
    FAILED
}

每个状态的注释标注了它是"AI 节点"、"确定性节点"还是"人工节点",一目了然。

2.4 实战:退款客服 Agent(RefundWorkflowService)

退款场景是一个非常典型的混合流程——既要理解用户自然语言(AI 擅长),又有金钱和业务规则(必须确定性)。来看项目中 RefundWorkflowServicecom.jichi.agent.service.RefundWorkflowService)的完整实现:

第一步:AI 意图识别

注意构造函数中,这个 ChatClient 专门用于意图分类和内容生成,不挂任何工具——职责单一:

public RefundWorkflowService(@Qualifier("dashScopeChatModel") ChatModel chatModel,
                             OrderRepository orderRepository,
                             RefundService refundService,
                             NotificationService notificationService,
                             HumanInTheLoopService humanInTheLoopService) {
    // 这个 ChatClient 专门用于意图分类和内容生成,不挂任何工具
    // 职责单一:只做语言理解,不做任何业务操作
    this.aiClient = ChatClient.builder(chatModel)
            .defaultSystem("你是一个意图分类助手,只输出分类结果,不要解释。")
            .build();
    // ... 其他依赖注入
}

意图分类方法强制输出格式,防止 AI 自由发挥输出无法解析的内容:

// AI 节点:意图分类
private String classifyIntent(String message) {
    return aiClient.prompt()
            .user("""
                    分类用户意图,只输出以下之一(不要有其他文字):
                    FULL_REFUND(全额退款)
                    PARTIAL_REFUND(部分退款)
                    EXCHANGE(换货)
                    COMPLAINT(投诉,不需要退款)

                    用户说:""" + message)
            .call()
            .content()
            .strip();
}

第二步:确定性校验——权限和订单

// ——— 确定性节点:权限和订单校验 ———
// 这两步绝对不能交给 AI:AI 可能被"我真的很需要这笔钱"这种描述影响判断
Order order = orderRepository.findById(orderId).orElse(null);
if (order == null) {
    return WorkflowResult.fail("订单不存在:" + orderId);
}
if (!order.getUserId().equals(userId)) {
    return WorkflowResult.fail("无权操作此订单");
}

第三步:确定性校验——业务规则

// 确定性节点:退款资格校验
private RefundEligibility checkEligibility(Order order) {
    long daysSincePurchase = ChronoUnit.DAYS.between(
            order.getCreatedAt().toLocalDate(), LocalDate.now());

    if (daysSincePurchase > 7) {
        return RefundEligibility.deny(
                "已超过 7 天无理由退货期限(购买于 " + daysSincePurchase + " 天前)");
    }

    if ("SHIPPED".equals(order.getStatus()) && order.getSignedAt() == null) {
        return RefundEligibility.deny("商品正在配送中,请收货后申请退款");
    }

    if ("REFUNDED".equals(order.getStatus())) {
        return RefundEligibility.deny("订单已退款,不可重复申请");
    }

    return RefundEligibility.pass();
}

第四步:确定性计算——退款金额

switch 表达式根据意图计算金额,精确可控:

// 确定性节点:退款金额计算
private double calculateRefundAmount(Order order, String intent) {
    return switch (intent) {
        case "FULL_REFUND", "EXCHANGE" -> order.getActualAmount();
        case "PARTIAL_REFUND" -> order.getActualAmount() * 0.5;
        default -> order.getActualAmount();
    };
}

第五步:人工审批节点(大额退款)

规则明确(> 1000 元),触发条件是确定性的;但审批决定权交给人类:

// ——— 人工审批节点:大额退款需要主管确认 ———
if (refundAmount > 1000) {
    String workflowId = UUID.randomUUID().toString();
    humanInTheLoopService.requestApproval(
            workflowId,
            "大额退款申请:¥" + String.format("%.2f", refundAmount) + ",订单:" + orderId,
            "supervisor@company.com",
            Map.of("orderId", orderId, "userId", userId, "amount", refundAmount)
    );
    return WorkflowResult.pending("退款申请已提交,金额较大需人工审核,预计 1 小时内处理");
}

第六步:执行退款 + AI 生成友好回复 + 确定性通知

// ——— 确定性节点:执行退款(≤ 1000 元直接处理)———
refundService.process(orderId, refundAmount);

// ——— AI 节点:生成用户友好的确认回复 ———
String reply = generateApprovalReply(order, refundAmount);

// ——— 确定性节点:发送通知 ———
notificationService.sendRefundNotification(userId, orderId, refundAmount);

return WorkflowResult.success(reply, refundAmount);

其中 AI 生成回复的方法,AI 只负责"怎么说",不决定"能不能退":

private String generateApprovalReply(Order order, double refundAmount) {
    return aiClient.prompt()
            .system("你是一个专业、友善的客服")
            .user(String.format("退款已通过。订单 %s,退款金额 ¥%.2f,请生成简短的确认回复,让用户知道款项会在 3-5 个工作日内到账",
                    order.getId(), refundAmount))
            .call()
            .content();
}

2.5 Human-in-the-Loop:人工审批节点

来看 HumanInTheLoopServicecom.jichi.agent.support.HumanInTheLoopService)的实现:

@Service
public class HumanInTheLoopService {

    private final PendingApprovalRepository approvalRepo;
    private final NotificationService notificationService;

    /**
     * 高风险操作暂停,等待人工审批
     * 触发场景:大额退款、批量操作、敏感数据修改
     */
    public void requestApproval(String workflowId, String description,
                                  String approverEmail, Object payload) {
        PendingApproval approval = new PendingApproval(
                workflowId, description, approverEmail,
                payload, "PENDING", null, LocalDateTime.now());

        approvalRepo.save(approval);

        // 通知审批人——这一步是确定性的,必须发
        notificationService.notifyApprover(approverEmail,
                "需要你审批:" + description +
                "\n审批链接:https://admin/approve/" + workflowId);
    }

    /** 审批通过,恢复流程继续执行 */
    public void onApproved(String workflowId) {
        approvalRepo.updateStatus(workflowId, "APPROVED", null);
        // 实际项目这里触发工作流引擎恢复(Activiti / Flowable 等)
    }

    /** 审批拒绝,终止流程并通知用户 */
    public void onRejected(String workflowId, String reason) {
        approvalRepo.updateStatus(workflowId, "REJECTED", reason);
    }

    /** 查询审批状态(供轮询) */
    public String getStatus(String workflowId) {
        return approvalRepo.findById(workflowId)
                .map(PendingApproval::status)
                .orElse("NOT_FOUND");
    }
}

在生产环境中,通常会结合公司的 OA 审批系统来实现;教学场景下可以用轮询接口来查询审批状态。

2.6 切忌一刀切

千万不要走两个极端:

  • 全交给 AI:迟早出事故,金钱、权限相关的操作一旦被绕过,损失不可逆。
  • 全用确定性代码:你搞不定自然语言的意图识别和灵活的内容生成。

正确做法永远是混合使用,让 AI 和确定性代码各司其职。


三、Agent 生产级可靠性:上线前的"生死清单"

Agent 在本地跑得好好的,上了生产就暴露各种问题。以下是实战中总结的几个关键防护措施。

3.1 防死循环:最大调用次数限制(MaxIterationsAdvisor)

Agent 最可怕的事情之一:模型反复调用同一个工具,拿不到想要的答案,或者拿到了也不结束,就在那转圈。工具返回"查询失败,请重试",模型就真的重试了——又失败又重试,Token 耗尽才停下来。

解决方案:基于 Spring AOP 拦截器,为所有 @Tool 方法设置调用上限。来看 MaxIterationsAdvisorcom.jichi.agent.advisor.MaxIterationsAdvisor):

/**
 * 用 Spring AOP 拦截所有 @Tool 方法,统计当前请求的工具调用次数。
 * 比实现 Spring AI 内部 Advisor 接口更稳定,不依赖框架私有 API。
 */
@Aspect
@Component
public class MaxIterationsAdvisor {

    private static final int DEFAULT_MAX_ITERATIONS = 10;

    // ThreadLocal 保证同一请求内的计数互不干扰
    private static final ThreadLocal<AtomicInteger> CALL_COUNT =
            ThreadLocal.withInitial(AtomicInteger::new);

    /** 每次新 Agent 任务开始前调用,重置计数器 */
    public static void reset() {
        CALL_COUNT.remove();
    }

    /** 拦截项目内所有标注了 @Tool 的方法 */
    @Around("@annotation(org.springframework.ai.tool.annotation.Tool)")
    public Object limitToolCalls(ProceedingJoinPoint joinPoint) throws Throwable {
        int count = CALL_COUNT.get().incrementAndGet();

        if (count > DEFAULT_MAX_ITERATIONS) {
            CALL_COUNT.remove();
            throw new AgentMaxIterationsException(
                    "Agent 超过最大工具调用次数(" + DEFAULT_MAX_ITERATIONS + "),任务中止");
        }

        return joinPoint.proceed();
    }
}

自定义异常类 AgentMaxIterationsException 很简洁:

public class AgentMaxIterationsException extends RuntimeException {
    public AgentMaxIterationsException(String message) {
        super(message);
    }
}

调用次数上限经验值
- 简单任务:3~5 次
- 分析类任务:8~12 次
- 超过 15 次:考虑是不是该把任务拆开

3.2 超时控制 + 统一错误处理:SafeAgentController

工具调用可能因为数据库慢查询、第三方 API 超时、网络抖动等原因卡住。来看 SafeAgentControllercom.jichi.agent.controller.SafeAgentController)如何实现三层防护——超时、死循环、预算:

@RestController
@RequestMapping("/api/agent")
public class SafeAgentController {

    private final PersonalAssistantAgent agent;

    @PostMapping("/chat")
    public ResponseEntity<AgentResponse> chat(@RequestBody ChatRequest request) {
        try {
            // 套一层 CompletableFuture,统一控制整体任务超时
            String result = CompletableFuture
                    .supplyAsync(() -> agent.chat(request.message()))
                    .get(60, TimeUnit.SECONDS);
            return ResponseEntity.ok(AgentResponse.success(result));

        } catch (AgentMaxIterationsException e) {
            return ResponseEntity.ok(AgentResponse.error(
                    "这个问题比较复杂,建议拆分成几个小问题分别来问",
                    "MAX_ITERATIONS"));

        } catch (TimeoutException e) {
            return ResponseEntity.ok(AgentResponse.error(
                    "处理时间较长,已超出等待上限,请稍后重试或尝试更简单的问题",
                    "TIMEOUT"));

        } catch (Exception e) {
            Throwable cause = e.getCause() != null ? e.getCause() : e;
            if (cause instanceof AgentMaxIterationsException) {
                return ResponseEntity.ok(AgentResponse.error(
                        "这个问题比较复杂,建议拆分成几个小问题分别来问",
                        "MAX_ITERATIONS"));
            }
            if (cause.getMessage() != null && cause.getMessage().contains("Token 预算")) {
                return ResponseEntity.ok(AgentResponse.error(
                        "这个问题处理量超出单次限额,请拆分成更小的问题",
                        "BUDGET_EXCEEDED"));
            }
            return ResponseEntity.status(500)
                    .body(AgentResponse.error("服务暂时开了个小差,请稍后重试", "INTERNAL_ERROR"));
        }
    }

    record AgentResponse(boolean success, String content, String errorCode) {
        static AgentResponse success(String content) {
            return new AgentResponse(true, content, null);
        }
        static AgentResponse error(String message, String code) {
            return new AgentResponse(false, message, code);
        }
    }
}

注意一个细节:超时和死循环都返回 200 状态码 + 友好提示,不要抛 500 给前端。用户看到"服务器内部错误"只会一脸懵,但看到"建议拆分成几个小问题"就知道该怎么办了。同时 AgentResponse 带有 errorCodeMAX_ITERATIONSTIMEOUTBUDGET_EXCEEDED),前端可以根据不同错误码做不同的 UI 提示。

3.3 Token 成本控制(TokenUsageLogger)

Agent 的多轮调用会快速消耗 Token。必须加监控 + 预算限制。来看 jc-sales-agent 项目中 TokenUsageLoggercom.jichi.salesAgent.config.TokenUsageLogger)的实现:

@Component
@Slf4j
public class TokenUsageLogger implements ChatModelListener {

    private final Counter inputTokenCounter;
    private final Counter outputTokenCounter;

    public TokenUsageLogger(MeterRegistry meterRegistry) {
        this.inputTokenCounter = Counter.builder("llm.tokens.input")
                .description("Input tokens consumed")
                .register(meterRegistry);
        this.outputTokenCounter = Counter.builder("llm.tokens.output")
                .description("Output tokens consumed")
                .register(meterRegistry);
    }

    @Override
    public void onResponse(ChatModelResponseContext responseContext) {
        var usage = responseContext.chatResponse().tokenUsage();
        if (usage != null) {
            int input = usage.inputTokenCount() != null ? usage.inputTokenCount() : 0;
            int output = usage.outputTokenCount() != null ? usage.outputTokenCount() : 0;

            inputTokenCounter.increment(input);
            outputTokenCounter.increment(output);

            // 估算费用(qwen-max 价格:输入 0.04 元/千Token,输出 0.12 元/千Token)
            double cost = input * 0.04 / 1000.0 + output * 0.12 / 1000.0;
            log.info("Token 用量 | 输入:{} | 输出:{} | 本次费用约:¥{}",
                    input, output, String.format("%.4f", cost));
        }
    }
}

它实现了 LangChain4j 的 ChatModelListener 接口,通过 Micrometer 的 Counter 将 Token 指标注册到监控系统。每次模型调用完成后自动记录输入/输出 Token 数并估算费用。这些指标可以接入 Prometheus + Grafana 做实时监控和告警。

预算怎么定? 先跑一批正常任务,看看平均消耗多少 Token,然后乘以 1.5~2 倍作为上限。

3.4 幻觉防护:HallucinationGuard

Agent 有一个隐蔽的陷阱:它可能假装调了工具,然后在回答里编造数据。比如让它查某商品上周的销量,它写了一个具体数字,看起来很真,但其实是瞎编的。如果这些数据流入报表,后果不堪设想。

解决方案:用一个轻量级模型做二次校验。来看 HallucinationGuardcom.jichi.agent.support.HallucinationGuard):

@Component
public class HallucinationGuard {

    private final ChatClient verifierClient;

    public HallucinationGuard(@Qualifier("dashScopeChatModel") ChatModel chatModel) {
        // 用轻量模型做验证,速度快成本低
        this.verifierClient = ChatClient.builder(chatModel)
                .defaultSystem("""
                        你是一个数据一致性检查员。
                        检查 Agent 的回答是否和工具实际返回的数据一致。
                        如果回答里出现了工具数据里没有的具体数字或事实,判定为不一致。
                        只输出 CONSISTENT 或 INCONSISTENT: [不一致的具体内容]
                        """)
                .build();
    }

    /**
     * 检查最终答案是否和工具数据一致
     *
     * @param toolResults  工具实际返回的数据(从日志或拦截层收集)
     * @param finalAnswer  Agent 生成的最终答案
     */
    public boolean isConsistent(String toolResults, String finalAnswer) {
        String result = verifierClient.prompt()
                .user("工具实际返回的数据:\n" + toolResults + "\n\nAgent 的回答:\n" + finalAnswer)
                .call()
                .content()
                .strip();

        return result.startsWith("CONSISTENT");
    }
}

幻觉检测不需要每条都做,只针对高精度要求的场景:财务报告、库存数字、价格信息等。普通问答类无所谓。

3.5 上线前 Checklist

在 Agent 上线前,务必逐条检查:

  • [ ] 最大工具调用次数是否设置(MaxIterationsAdvisor)
  • [ ] 单个工具是否有超时保护
  • [ ] 整体任务是否有超时限制(CompletableFuture + get(timeout))
  • [ ] Token 消耗是否有监控和预算(TokenUsageLogger)
  • [ ] 写操作是否有权限校验
  • [ ] 工具返回异常是否有兜底处理
  • [ ] 请求日志和降级策略是否就绪
  • [ ] 连续失败场景是否有熔断机制
  • [ ] 是否有明确的 Prompt 告知模型"不能做什么"

四、实战项目:智能销售数据分析 Agent(jc-sales-agent)

4.1 项目背景

假设你入职了一家电商公司,老板每天要看销售数据。传统方式是:老板提需求 -> 开发写接口 -> 测试 -> 部署,快则半天,慢则一两周。而且老板的需求永远在变——今天要区域对比,明天要销售排名,后天要异常检测,接口永远写不完。

有了 AI Agent 之后,老板直接用自然语言提问,Agent 自动分析并返回结果,甚至还能生成图表。

4.2 核心能力

这个项目能做到以下几件事:

  1. 追问下钻(多轮对话):用户先问"上个月华东区的销售情况",再追问"张伟和王芳各做了多少",接着问"张伟这个月有没有异常"——Agent 能保持上下文连续追问。

  2. 多步自主推理:问"本季度销量最大的销售员是谁",Agent 会自主查排名、对比数据、分析历史趋势,最后给出结论和可能的原因分析。

  3. 图表生成:用户说"帮我画一张近六个月各大区的销售趋势折线图",Agent 调用工具生成可视化图表。

  4. 主动预警:"最近有没有什么风险?"——Agent 扫描数据,按高、中、低优先级输出预警信息。

4.3 SalesAgent 接口定义

来看 Agent 的核心接口(com.jichi.salesAgent.agent.SalesAgent),使用 LangChain4j 的 @SystemMessage 注入业务规则和权限边界:

public interface SalesAgent {

    @SystemMessage("""
            你是一个专业的销售数据分析助手,服务于销售团队。

            【当前时间】今天是 {{today}}。
            请严格基于此日期理解所有时间相关词语:
            - "今天/当前" = {{today}}
            - "本月" = {{today}} 所在的自然月(1日至月末)
            - "上个月" = {{today}} 所在月的上一个自然月
            - "本季度" = {{today}} 所在季度(Q1:1-3月, Q2:4-6月, Q3:7-9月, Q4:10-12月)
            - "今年" = {{today}} 所在年份的 1月1日 至 12月31日
            - "近N个月" = 从 {{today}} 往前推 N 个自然月

            你的能力:
            - 查询销售订单数据
            - 计算销售汇总统计(总额、排名、Top N)
            - 分析同比环比趋势
            - 生成图表数据(ECharts JSON 格式)
            - 检测销售数据异常

            你的限制(严格遵守):
            - 只能查询数据,不能修改任何数据
            - 不能预测未来销售(没有预测能力)
            - 不能发送邮件、通知等操作
            - 如果问题超出能力范围,请明确告知并说明原因

            回答要求:
            - 用中文回答
            - 数据用具体数字,金额格式化为 ¥X,XXX
            - 有数据时给出简短的分析判断,不要只是罗列数据
            - 发现数据异常时主动提醒
            """)
    String chat(@MemoryId String sessionId, @UserMessage String message, @V("today") String today);
}

几个值得注意的设计:@MemoryId 标注 sessionId 支持多轮对话;@V("today") 模板变量让模型能正确理解"本月""上个月"等时间概念;System Prompt 明确列出了能力和限制,防止 Agent 越权操作。

4.4 Agent 装配:SalesAgentConfig

Agent 实例由 SalesAgentConfigcom.jichi.salesAgent.agent.SalesAgentConfig)手动创建 Bean,不依赖自动装配:

@Configuration
@RequiredArgsConstructor
public class SalesAgentConfig {

    private final ChatModel chatLanguageModel;
    private final StreamingChatModel streamingChatLanguageModel;
    private final SalesQueryTool salesQueryTool;
    private final SalesSummaryTool salesSummaryTool;
    private final SalesTrendTool salesTrendTool;
    private final ChartGeneratorTool chartGeneratorTool;
    private final AnomalyDetectionTool anomalyDetectionTool;
    private final MysqlChatMemoryStore chatMemoryStore;

    @Bean
    public SalesAgent salesAgent() {
        return AiServices.builder(SalesAgent.class)
                .chatModel(chatLanguageModel)
                .streamingChatModel(streamingChatLanguageModel)
                .tools(salesQueryTool,
                        salesSummaryTool,
                        salesTrendTool,
                        chartGeneratorTool,
                        anomalyDetectionTool)
                .beforeToolExecution(exec ->
                        log.info("▶ 工具调用开始 | 工具:{} | 参数:{}",
                                exec.request().name(),
                                exec.request().arguments()))
                .afterToolExecution(exec ->
                        log.info("◀ 工具调用完成 | 工具:{} | 结果长度:{} 字符",
                                exec.request().name(),
                                exec.result() != null ? exec.result().length() : 0))
                .chatMemoryProvider(memoryId ->
                        MessageWindowChatMemory.builder()
                                .id(memoryId)
                                .maxMessages(20)
                                .chatMemoryStore(chatMemoryStore)
                                .build())
                .build();
    }
}

5 个工具对象注入后由 AiServices.builder() 统一注册,.beforeToolExecution().afterToolExecution() 钩子记录每次工具调用的入参和结果长度,方便排查问题。chatMemoryProvider 配合 MysqlChatMemoryStore 实现了对话历史的持久化存储。

4.5 工具层设计亮点

SalesQueryToolcom.jichi.salesAgent.tool.SalesQueryTool)为例,看看工具描述的设计技巧:

@Tool("查询原始销售订单数据。适用于:查具体订单、看某时段订单列表、统计某时段订单总数。" +
     "【不适合】排名、增长率、图表生成、异常检测等场景,那些请使用对应的专用工具。")
public String queryOrders(
        @P("查询开始日期,格式 yyyy-MM-dd,如 2024-11-01") String startDate,
        @P("查询结束日期,格式 yyyy-MM-dd,如 2024-11-30") String endDate,
        @P("大区名称,如:华东区、华南区、华北区、西南区。传 null 或空字符串表示查全公司") String regionName,
        @P("销售员姓名,如需按特定销售员筛选则传入,如:张磊。否则传 null 或空字符串") String repName,
        @P("最多返回条数,默认 20,最大 50。避免返回数据过多") int limit) {
    // ...
}

工具描述里不仅说了"适用于"什么场景,还明确标注了"不适合"什么场景——这能有效避免模型选错工具。每个参数的 @P 描述都给出了格式示例和默认行为说明。

再看异常检测工具 AnomalyDetectionToolcom.jichi.salesAgent.tool.AnomalyDetectionTool),它无需任何参数,自动全面扫描四类异常:

@Tool("自动检测销售数据中的所有异常,包括:大区订单量骤降、产品连续零销售、" +
        "销售员退单率异常、销售员业绩骤降。适用于:有没有异常、风险排查、预警检测等场景。" +
        "无需传入参数,系统自动全面扫描。")
public String detectAllAnomalies() {
    List<AnomalyDTO> anomalies = new ArrayList<>();

    anomalies.addAll(detectRegionDropAnomalies());     // 大区订单量骤降
    anomalies.addAll(detectZeroSaleProducts());        // 产品连续零销售
    anomalies.addAll(detectHighRefundReps());          // 销售员退单率异常
    anomalies.addAll(detectRepPerformanceDrop());      // 销售员业绩骤降

    // 按优先级排序:HIGH > MEDIUM > LOW
    anomalies.sort((a, b) -> severityOrder(a.severity()) - severityOrder(b.severity()));
    // ... 格式化输出
}

4.6 输入安全:ToolInputValidator

为了防止 AI 传入恶意或无效参数,ToolInputValidatorcom.jichi.salesAgent.security.ToolInputValidator)对所有工具输入做白名单校验:

@Component
public class ToolInputValidator {

    private static final Set<String> VALID_REGIONS =
            Set.of("华东区", "华南区", "华北区", "西南区");

    private static final Set<String> VALID_CHART_TYPES =
            Set.of("line", "bar", "pie");

    private static final Set<String> VALID_DIMENSIONS =
            Set.of("region", "rep", "category");

    public String validateRegionName(String regionName) {
        if (regionName == null || regionName.isBlank()) return null;
        if (!VALID_REGIONS.contains(regionName)) {
            throw new IllegalArgumentException("无效的大区名称:" + regionName +
                    ",有效值为:" + VALID_REGIONS);
        }
        return regionName;
    }

    public int validateTopN(int topN) {
        return Math.min(Math.max(Math.abs(topN), 1), 20);
    }
    // ... 其他校验方法
}

大区名称、图表类型、维度等全部走白名单,日期走正则 + LocalDate.parse() 双重校验。这在防 SQL 注入和参数越界方面非常关键。

4.7 会话持久化:MysqlChatMemoryStore

对话历史落 MySQL,支持断点续聊(com.jichi.salesAgent.memory.MysqlChatMemoryStore):

@Component
@RequiredArgsConstructor
public class MysqlChatMemoryStore implements ChatMemoryStore {

    private final ChatMemoryRepository repository;

    @Override
    public List<ChatMessage> getMessages(Object memoryId) {
        String sessionId = memoryId.toString();
        return repository.findBySessionId(sessionId)
                .map(entity -> {
                    try {
                        return ChatMessageDeserializer.messagesFromJson(entity.getMessages());
                    } catch (Exception e) {
                        log.warn("反序列化对话记忆失败,sessionId={}", sessionId, e);
                        return Collections.<ChatMessage>emptyList();
                    }
                })
                .orElse(Collections.emptyList());
    }

    @Override
    public void updateMessages(Object memoryId, List<ChatMessage> messages) {
        String sessionId = memoryId.toString();
        String json = ChatMessageSerializer.messagesToJson(messages);
        ChatMemoryEntity entity = repository.findBySessionId(sessionId)
                .orElseGet(() -> {
                    ChatMemoryEntity e = new ChatMemoryEntity();
                    e.setSessionId(sessionId);
                    return e;
                });
        entity.setMessages(json);
        repository.save(entity);
    }

    @Override
    public void deleteMessages(Object memoryId) {
        repository.deleteBySessionId(memoryId.toString());
    }
}

用 LangChain4j 内置的 ChatMessageSerializer/ChatMessageDeserializer 做 JSON 序列化,配合 JPA 的 ChatMemoryRepository 实现增删改查。反序列化失败时返回空列表而不是抛异常,保证服务可用性。

4.8 与传统方式的本质区别

传统方式 Agent 方式
每个业务问题对应一个接口 5 个工具覆盖所有查询场景
新需求 = 新代码 新问题不需要写代码
展示什么写死在代码里 模型自主推理决定分析角度

Agent 通过工具化实现灵活性:SalesQueryTool 查原始订单、SalesSummaryTool 算排名汇总、SalesTrendTool 算同比环比、ChartGeneratorTool 生成 ECharts 图表、AnomalyDetectionTool 主动扫描异常——这 5 个工具组合起来,就能覆盖老板几乎所有的即兴提问。


五、总结

本文从四个维度梳理了 Agent 从"能跑"到"能用"的进阶路径:

  1. Plan-and-Execute 解决了 ReAct 在复杂任务中"没有全局视图"的问题。通过独立的 TaskPlannerStepExecutor,先规划再执行,步骤之间能传递上下文,还能动态调整计划。但记住:简单任务不要过度工程化。

  2. 确定性流程与 AI 决策的边界 是生产环境中最重要的设计原则之一。RefundWorkflowService 展示了如何让金钱、权限、不可逆操作走确定性代码,AI 只负责理解语言和生成内容。用 WorkflowState 状态机思维来设计混合工作流,每个节点的属性一目了然。

  3. 生产级可靠性 不是锦上添花,而是上线前的"生死线"。MaxIterationsAdvisor 防死循环、SafeAgentController 双层超时 + 统一错误处理、TokenUsageLogger Token 预算监控、HallucinationGuard 幻觉校验——这些都是真金白银买来的教训。上线前对着 Checklist 逐条过一遍,能省去大量线上救火的时间。

  4. 智能销售分析 Agent(jc-sales-agent) 展示了如何将上述所有知识融合到一个真实项目中。通过 SalesAgent 接口 + AiServices.builder() 装配 + 5 个专用工具类的架构,替代传统的"一个需求一个接口"模式,让 Agent 具备追问下钻、多步推理、图表生成和主动预警的能力,同时通过 ToolInputValidatorMysqlChatMemoryStoreTokenUsageLogger 兼顾安全、持久化和监控等生产要求。

Agent 开发的门槛正在快速降低,但从 Demo 到生产的鸿沟依然存在。掌握了本文这些工程实践,你就有了跨越这道鸿沟的底气。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐