本篇目标 :抛开 "时间/天气" 这种简单 Demo,用一个电商购物助手 Agent 真正学习 Agent 核心能力:需求理解、主动澄清、任务规划、工具执行、推荐总结。所有代码可直接落地到本项目。
一、为什么用电商购物助手学 Agent?
普通的 Function Calling 只是 "模型挑一个函数调用",根本算不上 Agent。一个真正的 Agent 至少要能:
- 理解模糊需求:把 "帮我买台电脑" 拆成结构化的品类/预算/用途/偏好;
- 判断信息是否充足 :信息不够时主动问用户,而不是瞎推荐;
- 规划多步骤任务:先搜索、再对比、再查选购指南、最后总结;
- 调用受控工具:每一步只能用白名单中的工具;
- 追踪执行过程:每一步用了什么工具、输入是什么、得到了什么结果,全部可观测;
- 基于事实总结:最终回复必须基于工具结果,不能编造商品。
电商购物天然具备多步决策、约束条件、候选比较、最终推荐这四个 Agent 必备特征,是最好的学习载体。
二、本篇关键概念
2.1 Agent(智能体)
含义:一个能 "感知---思考---行动" 循环的 AI 系统。不只是回答问题,而是为了完成某个目标,主动选择并调用工具,直到任务完成或达到边界条件。
作用:把 LLM 从 "聊天机器人" 升级为 "能做事的助手"。本项目中,购物 Agent 的目标是 "给用户合适的购买建议",过程中可以自主搜索商品、对比、查指南。
2.2 需求理解(Requirement Extraction)
含义 :从自然语言中提取结构化字段。例如把 "3000 以内主打拍照的手机" 解析为 {category=手机, budgetMax=3000, mustHave=[拍照好]}。
作用:结构化的需求才能驱动后续的工具调用和过滤。第一阶段用规则提取,第二阶段可以让模型直接输出 JSON。
2.3 主动澄清(Clarification)
含义:当关键字段(品类、预算、用途)缺失时,Agent 不应直接推荐,而是先反问用户。
作用 :避免基于错误假设给出无用建议。这是 Agent 区别于普通对话的关键能力------"知道自己不知道什么"。
2.4 规划(Planning)
含义:把目标拆解成有序的步骤序列。本项目购物 Agent 的固定计划是:搜索候选 → 对比候选 → 检索选购指南。
作用:把模糊目标变成可执行的步骤。第一阶段用固定模板,第二阶段可以让模型动态生成 Plan(输出 JSON 数组)。
2.5 工具(Tool)
含义 :Agent 可调用的外部能力的统一抽象。本项目定义了 ShoppingTool 接口,所有工具实现 name() / description() / supports() / execute() 四个方法。
作用:让 Agent 的行动空间标准化、可枚举、可白名单控制。
2.6 执行器(Executor)
含义 :循环遍历计划中的每一步,根据 toolName 找到对应工具,执行并记录 observation 和 status。
作用 :把 Plan 落到实际调用上,并提供 maxSteps 上限 防止 Agent 失控。
2.7 受控 Agent(Controlled Agent)
含义 :明确限制 Agent 能做什么、不能做什么。本项目第一阶段不允许下单/支付/改地址,且工具白名单只有四个。
作用:生产环境下,Agent 越自由越危险。学习阶段就要养成 "边界优先" 的习惯。
2.8 执行追踪(Trace)
含义:每一步的输入、输出、状态、耗时都记录下来,连同最终回复一起返回给前端。
作用:可观测性是 Agent 调试的命脉。前端可以把执行步骤渲染成卡片,让用户看到 Agent 的 "思考过程"。
三、整体架构
前端请求 POST /api/agent/shopping/run
│
▼
ShoppingAgentController.run()
│
▼
ShoppingAgentService.run() ← 总控
│
├── ShoppingRequirementService.extract() 需求提取
│ │
│ ├── needClarification == true → 直接返回澄清问题
│ └── needClarification == false → 进入规划
│
├── ShoppingPlannerService.createPlan() 生成计划(3 步)
│
├── ShoppingExecutorService.execute() 循环执行步骤
│ │
│ └── ShoppingToolRegistry.getTool() 工具白名单筛选
│ │
│ ├── ProductSearchTool 搜索 Mock 商品
│ ├── ProductCompareTool 生成对比维度
│ └── ShoppingGuideTool 走 RAG 知识库
│
└── ChatService.chat() 让 LLM 基于工具结果总结
│
▼
返回 ShoppingAgentResponse(含 needClarification / answer / steps)
四、包结构
为了避免和现有聊天/知识库/记忆代码混在一起,新增独立模块 agent.shopping:
src/main/java/com/wen/testai/agent/shopping/
├── controller/
│ └── ShoppingAgentController.java
├── dto/
│ ├── ShoppingAgentRequest.java
│ ├── ShoppingAgentResponse.java
│ ├── ShoppingAgentStepDTO.java
│ └── ProductDTO.java
├── model/
│ ├── ShoppingRequirement.java
│ ├── ShoppingPlan.java
│ ├── ShoppingStep.java
│ └── ShoppingStepStatus.java
├── service/
│ ├── ShoppingAgentService.java
│ ├── ShoppingRequirementService.java
│ ├── ShoppingPlannerService.java
│ ├── ShoppingExecutorService.java
│ └── ShoppingToolRegistry.java
└── tool/
├── ShoppingTool.java
├── MockProduct.java
├── MockProductRepository.java
├── ProductSearchTool.java
├── ProductCompareTool.java
├── ShoppingGuideTool.java
└── ShoppingListTool.java
五、模型层:把需求和计划结构化
5.1 ShoppingStepStatus(步骤状态枚举)
package com.wen.testai.agent.shopping.model;
public enum ShoppingStepStatus {
PENDING, // 待执行
RUNNING, // 正在执行
SUCCESS, // 成功
FAILED, // 失败
SKIPPED // 跳过(超过 maxSteps)
}
5.2 ShoppingRequirement(结构化需求)
@Data
@Builder
public class ShoppingRequirement {
private String category; // 品类:手机/电脑/厨房用品
private BigDecimal budgetMin; // 最低预算
private BigDecimal budgetMax; // 最高预算
private String usage; // 用途:办公、拍照、续航
private String brandPreference; // 品牌偏好
private List<String> mustHave; // 必须满足的卖点
private List<String> avoid; // 避免的特性
}
关键:所有字段都是 "可空",因为初次提取时几乎不可能字段全填齐------这正是 "主动澄清" 的依据。
5.3 ShoppingStep / ShoppingPlan
@Data
@Builder
public class ShoppingStep {
private int index;
private String description; // 人类可读描述
private String toolName; // 调用的工具名
private String input; // 工具输入
private ShoppingStepStatus status; // 状态
private String observation; // 工具返回结果
}
@Data
@Builder
public class ShoppingPlan {
private ShoppingRequirement requirement;
private List<ShoppingStep> steps;
}
六、工具层:统一抽象 + Mock 数据
6.1 ShoppingTool 接口
public interface ShoppingTool {
String name(); // 工具唯一名(product_search 等)
String description(); // 给 LLM/前端看的描述
boolean supports(String toolName); // 是否处理某个 toolName
String execute(String input); // 执行:第一阶段 String→String
}
第一阶段输入输出都用
String,便于学习和调试。后期链路稳定后再升级为结构化对象。
6.2 Mock 商品库
不接真实电商平台,避免被网络/反爬/价格实时性等问题干扰:
public record MockProduct(
String id,
String name,
String category,
BigDecimal price,
Double score,
List<String> highlights
) {}
@Repository
public class MockProductRepository {
private final List<MockProduct> products = List.of(
new MockProduct("p1001", "星河 X 手机", "手机", new BigDecimal("2799"), 4.7,
List.of("拍照清晰", "续航强", "屏幕素质好")),
new MockProduct("p1002", "青云 Pro 手机", "手机", new BigDecimal("2499"), 4.5,
List.of("轻薄", "充电快", "性价比高")),
new MockProduct("p2001", "远航 14 笔记本", "电脑", new BigDecimal("4999"), 4.6,
List.of("适合办公", "重量轻", "续航稳定"))
);
public List<MockProduct> search(String keyword, BigDecimal minPrice, BigDecimal maxPrice) {
return products.stream()
.filter(p -> keyword.contains(p.category()) || keyword.contains(p.name()))
.filter(p -> p.price().compareTo(minPrice) >= 0)
.filter(p -> p.price().compareTo(maxPrice) <= 0)
.toList();
}
}
6.3 ProductSearchTool(商品搜索)
@Component
public class ProductSearchTool implements ShoppingTool {
private final MockProductRepository productRepository;
public ProductSearchTool(MockProductRepository productRepository) {
this.productRepository = productRepository;
}
@Override public String name() { return "product_search"; }
@Override public String description() { return "根据品类、预算、用途和偏好搜索候选商品"; }
@Override public boolean supports(String toolName) { return name().equalsIgnoreCase(toolName); }
@Override
public String execute(String input) {
List<MockProduct> products = productRepository.search(input, BigDecimal.ZERO, new BigDecimal("999999"));
if (products.isEmpty()) {
return "没有找到匹配商品";
}
StringBuilder builder = new StringBuilder("候选商品:\n");
for (MockProduct p : products) {
builder.append("- ")
.append(p.id()).append(" | ")
.append(p.name()).append(" | ")
.append(p.price()).append("元 | ")
.append(p.score()).append("分 | ")
.append(String.join("、", p.highlights()))
.append("\n");
}
return builder.toString();
}
}
6.4 ProductCompareTool(商品对比)
第一阶段不做真实排序,先用文本模板告诉 LLM "按什么维度对比",让 LLM 在最后总结时自己组织对比内容:
@Component
public class ProductCompareTool implements ShoppingTool {
@Override public String name() { return "product_compare"; }
@Override public String description() { return "对候选商品进行价格、评分、卖点和适用场景对比"; }
@Override public boolean supports(String toolName) { return name().equalsIgnoreCase(toolName); }
@Override
public String execute(String input) {
return """
对比维度:
1. 价格:优先保留预算内商品
2. 评分:优先推荐评分较高商品
3. 卖点:匹配用户 mustHave 条件
4. 取舍:说明每个商品适合什么用户
待对比商品:%s
""".formatted(input);
}
}
6.5 ShoppingGuideTool(选购指南,对接 RAG)
复用教程四的 RagService,让 Agent 能从知识库中读 "怎么选":
@Component
public class ShoppingGuideTool implements ShoppingTool {
private final RagService ragService;
public ShoppingGuideTool(@Autowired(required = false) RagService ragService) {
this.ragService = ragService;
}
@Override public String name() { return "shopping_guide"; }
@Override public String description() { return "检索品类选购指南、参数解释和避坑建议"; }
@Override public boolean supports(String toolName) { return name().equalsIgnoreCase(toolName); }
@Override
public String execute(String input) {
if (ragService == null) {
return "知识库未启用,使用通用选购规则:看预算、用途、核心参数、售后和用户评价。";
}
String context = ragService.searchContext(input);
return context == null ? "知识库暂无相关选购指南" : context;
}
}
@Autowired(required = false)是关键:即使将来拆掉 RAG 模块,Agent 也能降级为 "通用规则",不会崩。
6.6 ShoppingListTool(购物清单)
为 "搬新家配厨房" 这类多商品场景预留:
@Component
public class ShoppingListTool implements ShoppingTool {
@Override public String name() { return "shopping_list"; }
@Override public String description() { return "根据预算和场景生成多商品购物清单"; }
@Override public boolean supports(String toolName) { return name().equalsIgnoreCase(toolName); }
@Override public String execute(String input) {
return "根据场景生成购物清单,控制总价不超过预算。用户需求:" + input;
}
}
七、服务层:需求提取 → 规划 → 执行 → 总控
7.1 ShoppingToolRegistry(工具注册中心 + 白名单)
Spring 自动把所有 ShoppingTool 注入成一个 List,再按 enabledTools 白名单过滤:
@Component
public class ShoppingToolRegistry {
private final List<ShoppingTool> tools;
public ShoppingToolRegistry(List<ShoppingTool> tools) {
this.tools = tools;
}
public ShoppingTool getTool(String toolName, List<String> enabledTools) {
return tools.stream()
.filter(t -> enabledTools == null || enabledTools.isEmpty() || enabledTools.contains(t.name()))
.filter(t -> t.supports(toolName))
.findFirst()
.orElseThrow(() -> new IllegalArgumentException("Unsupported shopping tool: " + toolName));
}
}
白名单为空 ⇒ 默认全开放;非空 ⇒ 只允许在列表里的工具。这层是 Agent 安全边界的第一道闸。
7.2 ShoppingRequirementService(规则式需求提取 + 澄清判断)
@Service
public class ShoppingRequirementService {
public ShoppingRequirement extract(String message) {
return ShoppingRequirement.builder()
.category(resolveCategory(message))
.budgetMax(resolveBudgetMax(message))
.usage(resolveUsage(message))
.mustHave(resolveMustHave(message))
.avoid(new ArrayList<>())
.build();
}
public boolean needClarification(ShoppingRequirement r) {
return r.getCategory() == null || r.getBudgetMax() == null || r.getUsage() == null;
}
public String buildClarificationQuestion(ShoppingRequirement r) {
List<String> qs = new ArrayList<>();
if (r.getCategory() == null) qs.add("你想买哪一类商品");
if (r.getBudgetMax() == null) qs.add("预算大概是多少");
if (r.getUsage() == null) qs.add("主要使用场景是什么");
return String.join("?", qs) + "?";
}
// 规则式提取(关键词命中)
private String resolveCategory(String msg) {
if (msg == null) return null;
if (msg.contains("手机")) return "手机";
if (msg.contains("电脑") || msg.contains("笔记本")) return "电脑";
if (msg.contains("厨房")) return "厨房用品";
return null;
}
// resolveBudgetMax / resolveUsage / resolveMustHave 见仓库源码
}
学习提示:第一阶段用规则提取最容易调试;等链路跑通,再把这部分换成 "让 LLM 输出 JSON",能立刻支持任意品类。
7.3 ShoppingPlannerService(生成 3 步固定计划)
@Service
public class ShoppingPlannerService {
public ShoppingPlan createPlan(ShoppingRequirement r) {
List<ShoppingStep> steps = new ArrayList<>();
steps.add(ShoppingStep.builder()
.index(1).description("搜索候选商品")
.toolName("product_search").input(buildSearchInput(r))
.status(ShoppingStepStatus.PENDING).build());
steps.add(ShoppingStep.builder()
.index(2).description("对比候选商品")
.toolName("product_compare").input(buildSearchInput(r))
.status(ShoppingStepStatus.PENDING).build());
steps.add(ShoppingStep.builder()
.index(3).description("检索选购指南")
.toolName("shopping_guide").input(r.getCategory() + " 选购指南")
.status(ShoppingStepStatus.PENDING).build());
return ShoppingPlan.builder().requirement(r).steps(steps).build();
}
private String buildSearchInput(ShoppingRequirement r) {
return r.getCategory() + " 预算" + r.getBudgetMax()
+ " 用途" + r.getUsage() + " 偏好" + r.getMustHave();
}
}
7.4 ShoppingExecutorService(带 maxSteps 限制的执行循环)
@Service
public class ShoppingExecutorService {
private final ShoppingToolRegistry toolRegistry;
public ShoppingExecutorService(ShoppingToolRegistry toolRegistry) {
this.toolRegistry = toolRegistry;
}
public ShoppingPlan execute(ShoppingPlan plan, List<String> enabledTools, int maxSteps) {
int executed = 0;
for (ShoppingStep step : plan.getSteps()) {
if (executed >= maxSteps) {
step.setStatus(ShoppingStepStatus.SKIPPED);
step.setObservation("超过最大执行步数,已跳过");
continue;
}
try {
step.setStatus(ShoppingStepStatus.RUNNING);
ShoppingTool tool = toolRegistry.getTool(step.getToolName(), enabledTools);
step.setObservation(tool.execute(step.getInput()));
step.setStatus(ShoppingStepStatus.SUCCESS);
} catch (Exception e) {
step.setObservation(e.getMessage());
step.setStatus(ShoppingStepStatus.FAILED);
}
executed++;
}
return plan;
}
}
maxSteps 是 Agent 的保险丝:哪怕 LLM 出现循环规划,最多跑 N 步就停。
7.5 ShoppingAgentService(总控)
整个 Agent 流水线的串联在这里:
@Service
public class ShoppingAgentService {
private final ShoppingRequirementService requirementService;
private final ShoppingPlannerService plannerService;
private final ShoppingExecutorService executorService;
private final ChatService chatService;
public ShoppingAgentService(ShoppingRequirementService requirementService,
ShoppingPlannerService plannerService,
ShoppingExecutorService executorService,
ChatService chatService) {
this.requirementService = requirementService;
this.plannerService = plannerService;
this.executorService = executorService;
this.chatService = chatService;
}
public ShoppingAgentResponse run(ShoppingAgentRequest request) {
// 1. 提取结构化需求
ShoppingRequirement requirement = requirementService.extract(request.getMessage());
// 2. 信息不足直接返回澄清
if (requirementService.needClarification(requirement)) {
return ShoppingAgentResponse.builder()
.needClarification(true)
.clarificationQuestion(requirementService.buildClarificationQuestion(requirement))
.requirements(requirement)
.products(List.of()).steps(List.of())
.build();
}
// 3. 规划 + 执行(带 maxSteps 上限)
int maxSteps = request.getMaxSteps() == null ? 5 : Math.min(request.getMaxSteps(), 10);
ShoppingPlan plan = plannerService.createPlan(requirement);
ShoppingPlan executed = executorService.execute(plan, request.getEnabledTools(), maxSteps);
// 4. 让 LLM 基于工具结果生成最终建议
String answer = chatService.chat(
buildRecommendationPrompt(request.getMessage(), executed),
request.getModel());
return ShoppingAgentResponse.builder()
.needClarification(false).answer(answer)
.requirements(requirement).products(List.of())
.steps(toStepDtos(executed.getSteps()))
.build();
}
private String buildRecommendationPrompt(String userMessage, ShoppingPlan plan) {
StringBuilder b = new StringBuilder();
b.append("你是一个电商购物助手 Agent。\n");
b.append("请根据用户需求和工具执行结果,给出具体、克制、可比较的购买建议。\n");
b.append("不要编造工具结果中不存在的商品价格、评分和参数。\n\n");
b.append("用户原始需求:").append(userMessage).append("\n\n");
b.append("结构化需求:").append(plan.getRequirement()).append("\n\n");
b.append("工具执行结果:\n");
for (ShoppingStep s : plan.getSteps()) {
b.append(s.getIndex()).append(". ").append(s.getDescription())
.append("\n工具:").append(s.getToolName())
.append("\n状态:").append(s.getStatus())
.append("\n结果:").append(s.getObservation()).append("\n\n");
}
b.append("请输出:推荐商品、推荐理由、适合人群、注意事项。");
return b.toString();
}
// toStepDtos 略
}
Prompt 工程要点:明确告诉模型 "只能基于工具结果回答,不准编造"。这是 Agent 防幻觉的最后一道闸门。
八、Controller 与 API
@RestController
@RequestMapping("/api/agent/shopping")
public class ShoppingAgentController {
private final ShoppingAgentService shoppingAgentService;
public ShoppingAgentController(ShoppingAgentService shoppingAgentService) {
this.shoppingAgentService = shoppingAgentService;
}
@PostMapping("/run")
public ResponseEntity<ShoppingAgentResponse> run(@RequestBody ShoppingAgentRequest request) {
return ResponseEntity.ok(shoppingAgentService.run(request));
}
}
8.1 调用示例 1:信息齐全 → 返回推荐
POST /api/agent/shopping/run
{
"message": "我想买一款 3000 元以内的手机,主要拍照和续航好",
"model": "qwen-plus",
"maxSteps": 5,
"enabledTools": ["product_search", "product_compare", "shopping_guide"]
}
响应:
{
"needClarification": false,
"answer": "推荐 星河 X 手机 / 青云 Pro 手机 ...",
"requirements": { "category": "手机", "budgetMax": 3000, "usage": "拍照、续航", "mustHave": ["拍照好","续航好"] },
"steps": [
{ "index": 1, "toolName": "product_search", "status": "SUCCESS", "observation": "候选商品:- p1001 ..." },
{ "index": 2, "toolName": "product_compare", "status": "SUCCESS", "observation": "对比维度:..." },
{ "index": 3, "toolName": "shopping_guide", "status": "SUCCESS", "observation": "知识库未启用,通用规则:..." }
]
}
8.2 调用示例 2:信息不足 → 返回澄清问题
POST /api/agent/shopping/run
{ "message": "帮我买一台电脑" }
响应:
{
"needClarification": true,
"clarificationQuestion": "预算大概是多少?主要使用场景是什么?",
"requirements": { "category": "电脑", "budgetMax": null, "usage": null }
}
九、问题点及调试
9.1 工具被多个实现 "抢走" 怎么办?
现象 :ShoppingToolRegistry.getTool("product_search", ...) 偶尔返回了错误的工具实例。
原因 :多个工具的 supports() 写错,把 equalsIgnoreCase 写成 contains,导致 product_search 也被 product_search_v2 命中。
调试:
- 单元测试每个
ShoppingTool.supports()是否严格等值; - 在
getTool()里 log 命中的工具名 + 类名,肉眼检查。
9.2 LLM 总在最后回复里编造商品价格
现象:工具明明返回 "没有找到匹配商品",最终回复却出现了 "XX 手机 2999 元" 这种凭空冒出的商品。
原因:Prompt 不够强势,模型沿用训练知识在脑补。
修复 :在 buildRecommendationPrompt 中加严格约束:
工具执行结果中如果出现"没有找到匹配商品",必须直接告诉用户没有找到,禁止编造。
并在测试用例里专门设计 "Mock 库为空" 的回归 case。
9.3 maxSteps 没生效,Agent 跑了 8 步
现象 :用户传了 maxSteps=3,但 Plan 里 3 步全跑完,第 4 步还想接着跑。
原因 :Math.min(maxSteps, 10) 写在了错的位置(在 plan 之前),导致后续动态扩展的 step 不受限。
修复 :把限制放在 Executor 内部循环里,按 executed >= maxSteps 判断(如代码 7.4 所示),而不是只限制 plan 长度。
9.4 ChatService.chat() 抛 NPE
现象:执行最后一步总结时 NPE。
原因 :request.getModel() 为 null,模型字段没传。
修复 :在 Controller 入口对 model 做默认值兜底(如取 application.properties 中配置的默认模型)。
9.5 RAG 知识库为空时 ShoppingGuideTool 报错
现象 :第三步 shopping_guide 报 "collection not found"。
原因 :项目没启用 Milvus,但 RagService 仍被注入,调用时报错。
修复 :用 @Autowired(required = false),并在 execute() 里判空降级为通用规则(已在 6.5 实现)。
9.6 前端拿到 steps 但 status 一直是 PENDING
现象:前端展示步骤列表,所有步骤状态都是 PENDING。
原因 :ShoppingExecutorService.execute() 没有把 status 写回到 step 对象。
修复 :确认 step.setStatus(...) 调用存在,并且返回的是同一个 Plan 引用。
十、本篇小结
- Agent 不只是 "能调函数",而是一个 理解 → 澄清 → 规划 → 执行 → 总结 的闭环系统。
- 电商购物 Agent 是最适合学习的场景,天然具备多步、多约束、多候选、需总结的特征。
- 工具抽象 + 工具注册中心 + 白名单 + maxSteps 是 Agent 的四大安全边界。
- 第一阶段用规则提取和固定 Plan,先把链路跑通再迭代,比上来就让 LLM 全自主规划稳得多。
- 生产环境下,Agent 永远不做交易,只做推荐。