Skip to content

小节项目:一个能聊、会查天气、记得住上下文的助手 ​

1. 本节产出 ​

把 01 篇的能力组装成一个完整应用:流式打字机输出 + 多轮记忆持久化 + 天气/时间工具调用 + 多厂商切换 + 异常降级 + 成本统计。一个能对外演示的东西,也是 01 篇的结课项目。

2. 前置依赖 ​

3. 为什么必须有这一节 ​

前面十一节都是「能力点」,学员能跑通每一段代码,但大概率拼不成一个东西。三个典型问题:

问题表现
组件打架记忆 Advisor 和流式输出一起用时,历史消息被重复写入
顺序错乱工具 Advisor 挂在记忆 Advisor 之前,导致工具结果没进记忆
职责不清成本统计写在 Controller 里,重试逻辑写在 Service 里,日志散落四处

组装本身就是一门课。单点能力人人都会,能把六个能力干净地拼在一起、且每个职责只在一处——这才是工程师价值的体现。这一节讲的就是「怎么拼」以及「拼的时候会撞到什么」。

4. 核心原理 ​

4.1 Advisor 链的顺序(本节最关键的知识点) ​

请求进入
   │
   ▼
[1] 审计 Advisor        记录原始输入(脱敏前最先拿到)
   ▼
[2] 安全 Advisor        敏感词 / 注入检测
   ▼
[3] 记忆 Advisor        注入历史消息(要在检索之前)
   ▼
[4] 检索 Advisor        RAG 场景(02 篇展开)
   ▼
[5] 工具调用            由框架在模型请求时触发
   ▼
模型调用
   │
   ▼
[6] 成本记录            读取 usage,落账
   ▼
响应返回

顺序原则:

位置该放什么为什么
最前审计、安全必须看到没有被任何加工过的原始输入
中间记忆、检索决定「模型看到什么上下文」
最后成本、追踪需要拿到最终结果才能统计

顺序错了不报错,但行为不对——这类 bug 是最难查的那一类。判断方法:打开 DEBUG 日志,看每个 Advisor 打印出的消息条数是否符合预期。

4.2 流式与记忆的配合陷阱 ​

流式模式下:
  Flux 在订阅后才开始消费
  记忆写入发生在「完整响应生成后」
  ↓
  如果客户端中途断开 → 响应不完整 → 记忆要不要写?

答案:写一个截断标记,或者干脆不写。 如果写了半截回答进记忆,下一轮模型会看到一段没说完的话,可能继续接着编。推荐做法:

java
.doFinally(signal -> {
    if (signal == SignalType.ON_COMPLETE) {
        memory.add(convId, List.of(new AssistantMessage(fullText.toString())));
    } else {
        log.info("流被中断,不写入记忆:signal={}", signal);
    }
})

这是本课时的隐藏考点:流式 + 记忆的组合有个必须显式处理的分支,前面的单节都没涉及。

4.3 分层结构 ​

Controller      只做参数校验与协议转换
   ↓
Facade          编排:记忆 → 工具 → 重试 → 降级
   ↓
Advisor         横切:审计、成本、安全
   ↓
ChatModel       厂商适配

Facade 层是这一节新增的。前面各节为了讲清单点,逻辑都直接写在 Controller 里。组装时必须抽出来,否则重试、降级、成本统计会互相纠缠。

5. 代码走查 ​

5.1 完整配置 ​

java
// src/main/java/com/example/aibasics/config/AssistantConfig.java
@Configuration
public class AssistantConfig {

    @Bean
    public ChatClient assistantClient(ChatClient.Builder builder,
                                       ChatMemory chatMemory,
                                       WeatherTools weatherTools,
                                       AuditAdvisor audit,
                                       CostAdvisor cost) {
        return builder
                .defaultSystem("""
                        你是一个实用的助理。
                        涉及天气、日期、时间等实时信息时必须调用工具,不得凭记忆回答。
                        工具返回 NOT_FOUND 时向用户确认,不要编造。
                        回答简洁,控制在 300 字内。
                        """)
                .defaultAdvisors(
                        audit,                                        // order = 100
                        MessageChatMemoryAdvisor.builder()
                                .chatMemory(chatMemory).build(),      // order = 200
                        cost)                                         // order = 900
                .defaultTools(weatherTools, new DateTimeTools())
                .build();
    }
}

5.2 Advisor 的顺序怎么定 ​

java
// src/main/java/com/example/aibasics/advisor/AuditAdvisor.java
@Component
public class AuditAdvisor implements CallAroundAdvisor {

    @Override
    public AdvisedResponse aroundCall(AdvisedRequest req, CallAroundAdvisorChain chain) {
        log.info("[audit] conv={} text={}",
                req.adviseContext().get(ChatMemory.CONVERSATION_ID),
                mask(req.userText()));                  // 脱敏后再记
        return chain.nextAroundCall(req);
    }

    @Override public String getName() { return "audit"; }
    @Override public int getOrder() { return 100; }     // 越小越靠前
}
java
// src/main/java/com/example/aibasics/advisor/CostAdvisor.java
@Component
public class CostAdvisor implements CallAroundAdvisor {

    @Override
    public AdvisedResponse aroundCall(AdvisedRequest req, CallAroundAdvisorChain chain) {
        long t0 = System.currentTimeMillis();
        AdvisedResponse resp = chain.nextAroundCall(req);

        var usage = resp.response().getMetadata().getUsage();
        Metrics.record(usage.getTotalTokens(), System.currentTimeMillis() - t0);
        return resp;
    }

    @Override public String getName() { return "cost"; }
    @Override public int getOrder() { return 900; }
}

5.3 Facade:把编排逻辑收口 ​

java
// src/main/java/com/example/aibasics/service/AssistantFacade.java
@Service
public class AssistantFacade {

    private final ChatClient client;
    private final ResilientChatService resilient;

    /** 流式对话:不写记忆(由 Controller 在完成后补写) */
    public Flux<String> stream(String convId, String question) {
        return client.prompt()
                .user(question)
                .advisors(a -> a.param(ChatMemory.CONVERSATION_ID, convId))
                .stream()
                .content()
                .doFinally(signal -> log.info("stream end conv={} signal={}", convId, signal));
    }

    /** 同步对话:走重试与降级 */
    public String chat(String convId, String question) {
        return resilient.chatWithMemory(convId, question);
    }
}

5.4 Controller:保持极薄 ​

java
// src/main/java/com/example/aibasics/controller/AssistantController.java
@RestController
@RequestMapping("/api/assistant")
public class AssistantController {

    private final AssistantFacade facade;

    @GetMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
    public Flux<String> stream(@RequestParam String convId,
                               @RequestParam @NotBlank String q) {
        return facade.stream(convId, q);
    }

    @PostMapping("/chat")
    public String chat(@RequestParam String convId, @RequestBody @NotBlank String q) {
        return facade.chat(convId, q);
    }

    @GetMapping("/cost")
    public CostSummary cost(@RequestParam String convId) {
        return Metrics.summary(convId);
    }
}

5.5 一键启动依赖 ​

yaml
# docker-compose.yml
services:
  postgres:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: chat
      POSTGRES_USER: sa
      POSTGRES_PASSWORD: sa
    ports: ["5432:5432"]
    volumes:
      - ./schema-memory.sql:/docker-entrypoint-initdb.d/init.sql
  redis:
    image: redis:7-alpine
    ports: ["6379:6379"]

6. 跑起来 ​

bash
git checkout ch01-12-mini-assistant
docker compose up -d
export DEEPSEEK_KEY=sk-xxx
mvn spring-boot:run
# 打开 http://localhost:8080

验收清单(逐条打勾,这是本节的核心):

bash
# 1. 流式打字机
curl -N "http://localhost:8080/api/assistant/stream?convId=c1&q=讲讲Spring事务"
# 期望:逐字输出

# 2. 工具调用
curl -N "http://localhost:8080/api/assistant/stream?convId=c1&q=北京今天天气如何"
# 期望:触发天气工具,返回真实数据而非编造

# 3. 记忆
curl -X POST "http://localhost:8080/api/assistant/chat?convId=c1" -d "我叫木鱼"
curl -X POST "http://localhost:8080/api/assistant/chat?convId=c1" -d "我叫什么"
# 期望:回答木鱼

# 4. 会话隔离
curl -X POST "http://localhost:8080/api/assistant/chat?convId=c2" -d "我叫什么"
# 期望:答不知道

# 5. 重启不失忆
# 重启应用后重跑第 3 步,仍然答木鱼

# 6. 降级
# 配一个错误 Key,请求应返回友好文案而非 500

# 7. 成本
curl "http://localhost:8080/api/assistant/cost?convId=c1"
# 期望:返回累计 Token 与估算成本
检查项通过标准
七项验收全部通过
Advisor 顺序DEBUG 日志显示 audit → memory → cost
中断不写记忆流式中断后重问,不会引用半截回答
成本可查/cost 返回非零且与厂商控制台量级一致

7. 生产避坑 ​

  1. Advisor 顺序错了不会报错,只会「效果不对」。这是最难排查的一类问题。务必在上线前用 DEBUG 日志确认一次实际执行顺序,并把顺序写进文档。判断口诀:审计在前、记忆在中、成本在后。
  2. 流式中断时的记忆处理必须显式写。默认行为可能是写入半截内容,下一轮模型会接着这段没说完的话继续编,产生非常诡异的对话。做法是只在 signal == ON_COMPLETE 时写记忆。
  3. 成本统计要用厂商返回的 usage,不要累加自己的估算。估算用于预算告警,统计用于对账,两者混用会导致月底和账单对不上。这个应用虽然小,但习惯要从第一天养成。

8. 延伸与锚点 ​

  • 思考题:现在这个助手只能「回答问题」。如果要它「帮我查订单并改状态」,需要新增哪些能力?(提示:权限分级、审批、审计——这是 03 篇 Agent 的主线)
  • 代码锚点:git checkout ch01-12-mini-assistant
  • 篇章完成:01 篇结束,进入 02 篇 RAG 工程化
  • 对应课件:L01-12 小节项目