Appearance
小节项目:一个能聊、会查天气、记得住上下文的助手
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. 生产避坑
- Advisor 顺序错了不会报错,只会「效果不对」。这是最难排查的一类问题。务必在上线前用 DEBUG 日志确认一次实际执行顺序,并把顺序写进文档。判断口诀:审计在前、记忆在中、成本在后。
- 流式中断时的记忆处理必须显式写。默认行为可能是写入半截内容,下一轮模型会接着这段没说完的话继续编,产生非常诡异的对话。做法是只在
signal == ON_COMPLETE时写记忆。 - 成本统计要用厂商返回的 usage,不要累加自己的估算。估算用于预算告警,统计用于对账,两者混用会导致月底和账单对不上。这个应用虽然小,但习惯要从第一天养成。
8. 延伸与锚点
- 思考题:现在这个助手只能「回答问题」。如果要它「帮我查订单并改状态」,需要新增哪些能力?(提示:权限分级、审批、审计——这是 03 篇 Agent 的主线)
- 代码锚点:
git checkout ch01-12-mini-assistant - 篇章完成:01 篇结束,进入 02 篇 RAG 工程化
- 对应课件:L01-12 小节项目