第 05 章:短期记忆:用 sessionId 串起一轮会话上下文

第 05 章:短期记忆:用 sessionId 串起一轮会话上下文

本章最终效果

上一章我们把"确定性计算"从模型回答里拆出来,放进了服务端 Tool Registry。

这一章我们解决另一个更容易被误解的问题:短期记忆。

很多同学第一次做聊天应用时,会以为模型天然知道上一轮说过什么。实际上,模型每次收到的只是本轮请求里的 messages。它不会自动记住上一轮,也不会自动知道这个用户是谁、上次打卡是什么、有没有膝盖疼。

本项目当前阶段的"短期记忆"不是完整聊天历史系统,而是三件事:

  1. 前端把上一轮返回的 sessionId 带回后端,让多轮请求属于同一个会话。
  2. Spring 后端不相信前端传来的用户上下文,只从 JWT 推导当前用户,然后补齐 profile 和最近 7 条 checkin。
  3. Python Agent 每一轮都重新把用户消息、档案、最近打卡、RAG 引用和行为要求组装成模型可读上下文。

完成本章后,你应该能看懂这条链路:

text 复制代码
Web message/sessionId
        |
        v
Spring Authentication -> userId -> profile + recentCheckins
        |
        v
Python AgentRequest -> _build_user_context()
        |
        v
LLM 本轮看到完整上下文

这里要特别注意边界:

  • 已实现:sessionId 连续性、可信上下文重建、trace 记录过程。
  • 未实现:完整聊天消息落库、历史消息回放、完整 session 管理后台。
  • 当前 GET /api/coach/sessions/{sessionId} 代理的是 trace 查询,不是完整聊天历史 API。

本章复制规则

本章继续使用统一标记:

  • [执行命令]:在终端复制运行。
  • [写入文件]:把完整代码复制到指定文件。
  • [理解片段,不要复制]:只用于理解代码位置、错误写法或源码片段,不要写进项目。

本章只有 CoachController.java 需要作为完整文件写入。Web 片段和 Python _build_user_context() 都是理解片段,因为完整文件已经在前面章节写过。

执行目录约定

如果命令前写着:

bash 复制代码
cd /Users/aibu/Aibu_System/Work_Projects/codex-template

说明你要回到项目根目录。

如果命令前写着:

bash 复制代码
cd services/backend

说明你要进入 Spring Boot 后端目录。

如果命令前写着:

bash 复制代码
cd services/agent-service

说明你要进入 Python Agent Service 目录。

如果你不是在我的电脑上复现,把绝对路径替换成你自己的项目路径即可。

阶段 1:先讲清楚本章的"短期记忆"到底是什么

1.1 为什么模型不会自动记住上一轮

我们先用一个简单场景理解问题。

第一轮用户说:

text 复制代码
我想减脂但不想掉力量。

第二轮用户说:

text 复制代码
那今天膝盖疼还能练腿吗?

如果第二轮请求只把"那今天膝盖疼还能练腿吗?"发给模型,模型会缺少几个关键信息:

  • 这个人是谁?
  • 他的目标是什么?
  • 他上一轮说过想减脂但不想掉力量吗?
  • 他的档案里有没有训练经验和伤病史?
  • 最近打卡里的睡眠、疲劳、疼痛是多少?

所以短期记忆不是"让模型自己记住",而是服务端每一轮把必要上下文重新组装好。

1.2 理解片段,不要复制 错误的第一版

一个很常见但不够好的写法是:

tsx 复制代码
body: JSON.stringify({
  userId,
  message,
  profile,
  recentCheckins
})

这段代码看起来省事,因为前端把所有上下文都传给后端。

但它有严重问题:

  • 前端传的 userId 可以被伪造。
  • 前端传的 profile 可以被篡改。
  • 前端传的 recentCheckins 不一定来自数据库。
  • 后端失去了用户隔离和数据可信度。

所以本项目的原则是:

前端只传用户本轮消息和 sessionId,可信用户上下文必须由后端自己补。

1.3 本章真实链路

本章的正确链路是:

  1. Web 第一次发送 Coach 消息时,sessionId 为空。
  2. Python Agent 如果没有收到 sessionId,会生成一个新的。
  3. Web 收到响应后,把 sessionId 存在 coachResponse 里。
  4. Web 第二次发送 Coach 消息时,把上一轮 sessionId 带回后端。
  5. Spring 后端从 Authentication 里拿当前用户,不从请求体里拿 userId
  6. Spring 后端查询当前用户的 profile 和最近 7 条 checkin。
  7. Spring 后端把 sessionIdmessageprofilerecentCheckins 传给 Python Agent。
  8. Python Agent 通过 _build_user_context() 组装本轮模型上下文。

这个实现的重点不是"模型脑子里记住了",而是"每轮请求都带着足够的可信上下文"。

1.4 执行命令 先确认本章依赖的上一章文件存在

执行目录:项目根目录。

bash 复制代码
cd /Users/aibu/Aibu_System/Work_Projects/codex-template
test -f apps/web/src/App.tsx
test -f services/backend/src/main/java/com/aibu/coachagent/business/CoachController.java
test -f services/backend/src/main/java/com/aibu/coachagent/business/ApiDtos.java
test -f services/agent-service/app/service.py

预期输出:没有任何输出。

如果命令失败,说明前面章节的工程文件还没有补齐。先回到第 01-03 章检查基础工程,再继续本章。

阶段 2:Web 只负责带回 sessionId

2.1 这一阶段要解决什么

前端在短期记忆里只做一件事:把上一轮响应里的 sessionId 带回下一轮请求。

它不负责拼接历史消息,不负责传用户档案,不负责传最近打卡,更不负责传 userId

这样做的好处是:

  • 前端逻辑简单。
  • 后端仍然掌握可信用户身份。
  • Agent Service 可以用同一个 sessionId 标识同一段会话。

2.2 理解片段,不要复制 当前 Web 发送 Coach 消息的片段

下面这段来自 apps/web/src/App.tsx,只用于理解,不要单独复制覆盖整个文件。

tsx 复制代码
async function sendCoachMessage(customMessage = message) {
  setBusy('chat');
  setError(null);
  try {
    const result = await api.request<any>('/api/coach/chat', {
      method: 'POST',
      body: JSON.stringify({ message: customMessage, sessionId: coachResponse?.sessionId })
    });
    setCoachResponse(result);
    await loadTraces();
  } catch (exc) {
    setError(String(exc));
  } finally {
    setBusy(null);
  }
}

2.3 代码分段解释

第一段:

tsx 复制代码
async function sendCoachMessage(customMessage = message) {

这个函数负责发送 Coach Chat。customMessage = message 表示如果外部没有传自定义消息,就使用页面输入框里的 message

第二段:

tsx 复制代码
body: JSON.stringify({ message: customMessage, sessionId: coachResponse?.sessionId })

这是本章前端最关键的一行。

  • message 是本轮用户输入。
  • coachResponse?.sessionId 是上一轮 Coach 响应里的会话 id。
  • ?. 表示如果 coachResponse 为空,就传 undefined

第一轮发送时,coachResponse 还不存在,所以 sessionId 为空。Python Agent 会生成新的 sessionId

第二轮发送时,coachResponse 已经有上一轮响应,所以前端会把同一个 sessionId 带回去。

第三段:

tsx 复制代码
setCoachResponse(result);
await loadTraces();

setCoachResponse(result) 会保存本轮响应,下一次发送时就能复用 result.sessionId

loadTraces() 会刷新 Trace 时间线,让我们在 Web 上看到 Agent 这一轮做了哪些步骤。

2.4 为什么前端不传 profile 和 recentCheckins

前端可以展示 profile,也可以让用户编辑 profile,但发起 Coach Chat 时,它不应该把 profile 当作可信上下文传给 Agent。

原因是:

  • 前端数据可以被浏览器 DevTools 修改。
  • 用户可以自己构造请求。
  • 如果后端相信前端传来的 profile,就可能出现用户伪造身份或污染上下文。

所以本章的工程判断是:

前端只传 message/sessionId,可信上下文由后端从数据库补。

2.5 执行命令 静态检查 Web 是否带回 sessionId

执行目录:项目根目录。

bash 复制代码
cd /Users/aibu/Aibu_System/Work_Projects/codex-template
rg -n "sessionId: coachResponse\\?\\.sessionId|/api/coach/chat|setCoachResponse|loadTraces" apps/web/src/App.tsx

预期输出中要能看到:

text 复制代码
body: JSON.stringify({ message: customMessage, sessionId: coachResponse?.sessionId })
setCoachResponse(result)
await loadTraces()

如果没有看到,先检查:

  • 当前文件是不是 apps/web/src/App.tsx
  • 前面章节是否误覆盖了 Web 主产品代码。
  • coachResponse?.sessionId 是否拼写正确。

2.6 执行命令 Web 轻量测试

执行目录:Web 目录。

bash 复制代码
cd apps/web
npm test

预期输出类似:

text 复制代码
Test Files  1 passed (1)
Tests  2 passed (2)

这一步不直接测试 Coach Chat 请求,但可以确认 Web 工程当前测试环境可用。第 05 章不新增前端测试,只做片段理解和静态检查。

阶段 3:Spring 后端补齐可信上下文

3.1 这一阶段要解决什么

前端只传了:

json 复制代码
{
  "message": "...",
  "sessionId": "..."
}

这还不够给 Agent 使用。

Python Agent 需要知道:

  • 当前用户是谁。
  • 用户档案是什么。
  • 最近打卡是什么。
  • 当前模式是 chat 还是 eval / red_team

这些信息应该由 Spring 后端补齐,因为 Spring 后端已经在第 02 章完成了 JWT 认证和用户隔离。

services/backend/src/main/java/com/aibu/coachagent/business/CoachController.java

这个文件为什么现在出现

CoachController 是 Web 和 Python Agent Service 之间的业务网关。

它不应该只是把前端请求原样转发给 Python,而应该先做三件事:

  1. Authentication 里拿当前用户 id。
  2. 从数据库读取当前用户 profile 和最近 checkin。
  3. 把可信上下文组装成 AgentRequestPayload 发给 Python Agent。
理解片段,不要复制 错误的第一版

先看一个错误版本:

java 复制代码
@PostMapping("/chat")
public JsonNode chat(@RequestBody AgentRequestPayload request) {
    return agentClient.chat(request);
}

这个版本非常危险。

它把前端传来的 userIdprofilerecentCheckins 全都当真。只要有人构造请求,就可以把别人的 userId 塞进去,或者伪造一份 profile。

所以最终版本不能这样写。

最终版本要怎么做

最终版本遵守这几个规则:

  • UUID userId = UUID.fromString(auth.getName()):当前用户只能来自 JWT 解析后的 Authentication
  • business.getProfile(userId, displayName(userId)):profile 从数据库读取。
  • business.listCheckins(userId, 7):最近 7 条打卡从数据库读取。
  • request.sessionId()request.message():前端只提供会话 id 和本轮消息。
  • mode 固定为 "chat":告诉 Python 这是普通 Coach Chat。
写入文件 services/backend/src/main/java/com/aibu/coachagent/business/CoachController.java

执行目录:项目根目录。

java 复制代码
package com.aibu.coachagent.business;

import com.aibu.coachagent.agent.AgentClient;
import com.aibu.coachagent.business.ApiDtos.AgentRequestPayload;
import com.aibu.coachagent.business.ApiDtos.CoachMessageRequest;
import com.aibu.coachagent.user.UserRepository;
import com.fasterxml.jackson.databind.JsonNode;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;

@RestController
@RequestMapping("/api/coach")
public class CoachController {
    private final BusinessRepository business;
    private final UserRepository users;
    private final AgentClient agentClient;

    public CoachController(BusinessRepository business, UserRepository users, AgentClient agentClient) {
        this.business = business;
        this.users = users;
        this.agentClient = agentClient;
    }

    @PostMapping("/chat")
    public JsonNode chat(Authentication auth, @RequestBody CoachMessageRequest request) {
        UUID userId = UUID.fromString(auth.getName());
        var profile = business.getProfile(userId, displayName(userId)).orElse(null);
        return agentClient.chat(new AgentRequestPayload(
                userId.toString(),
                request.sessionId(),
                request.message(),
                profile,
                business.listCheckins(userId, 7),
                "chat"));
    }

    @GetMapping("/sessions/{sessionId}")
    public JsonNode session(Authentication auth, @PathVariable String sessionId) {
        return agentClient.trace(sessionId);
    }

    private String displayName(UUID userId) {
        return users.findById(userId)
                .map(user -> user.displayName())
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "User not found"));
    }
}
代码分段解释

第一段:

java 复制代码
@RestController
@RequestMapping("/api/coach")
public class CoachController {

这说明当前类负责 /api/coach 下面的接口。

本章关注两个接口:

  • POST /api/coach/chat
  • GET /api/coach/sessions/{sessionId}

第二段:

java 复制代码
private final BusinessRepository business;
private final UserRepository users;
private final AgentClient agentClient;

这三个依赖各有职责:

  • BusinessRepository 读写业务数据,比如 profile 和 checkin。
  • UserRepository 查询用户账号信息,比如 displayName。
  • AgentClient 调用 Python Agent Service。

第三段:

java 复制代码
UUID userId = UUID.fromString(auth.getName());

这是本章安全边界的核心。

auth.getName() 来自 Spring Security 解析 JWT 后放入的认证上下文。后端相信它,不相信请求体里的用户身份。

第四段:

java 复制代码
var profile = business.getProfile(userId, displayName(userId)).orElse(null);

这里从数据库读取当前用户档案。

如果用户还没有建档,就传 null 给 Agent。这样 Agent 可以追问缺失信息,而不是假装知道用户档案。

第五段:

java 复制代码
business.listCheckins(userId, 7)

这里读取最近 7 条打卡。它就是本项目当前阶段的短期状态窗口。

为什么是 7 条?因为 Today 和 Coach Chat 通常关心最近一周的睡眠、疲劳、疼痛、体重变化,不需要每轮都把全部历史塞进 prompt。

第六段:

java 复制代码
@GetMapping("/sessions/{sessionId}")
public JsonNode session(Authentication auth, @PathVariable String sessionId) {
    return agentClient.trace(sessionId);
}

这里容易误解。

当前实现不是完整聊天历史查询。它只是把 sessionId 作为 trace id 代理给 Python Trace 查询,用于演示和排查。

不要把它讲成"已经实现了完整聊天记录系统"。

复制后立即运行:静态检查关键链路

执行目录:项目根目录。

bash 复制代码
cd /Users/aibu/Aibu_System/Work_Projects/codex-template
rg -n "auth\\.getName\\(\\)|business\\.getProfile|business\\.listCheckins\\(userId, 7\\)|request\\.sessionId\\(\\)|request\\.message\\(\\)|agentClient\\.chat" services/backend/src/main/java/com/aibu/coachagent/business/CoachController.java

预期输出中要看到这些关键点:

text 复制代码
auth.getName()
business.getProfile
business.listCheckins(userId, 7)
request.sessionId()
request.message()
agentClient.chat

如果缺少其中任何一个,说明 CoachController 没有复制完整,或者复制到了错误文件。

复制后立即运行:后端编译检查

执行目录:Spring Boot 后端目录。

bash 复制代码
cd services/backend
./gradlew compileJava --no-daemon

预期输出类似:

text 复制代码
BUILD SUCCESSFUL

如果失败,先检查:

  • CoachMessageRequestAgentRequestPayload 是否已经在 ApiDtos.java 中存在。
  • BusinessRepository 是否有 getProfile()listCheckins()
  • AgentClient 是否有 chat() 方法。
  • import 是否复制完整。

3.2 执行命令 检查 DTO 是否只接收 message/sessionId

执行目录:项目根目录。

bash 复制代码
cd /Users/aibu/Aibu_System/Work_Projects/codex-template
rg -n "record CoachMessageRequest|record AgentRequestPayload" services/backend/src/main/java/com/aibu/coachagent/business/ApiDtos.java

预期输出中要看到:

text 复制代码
public record CoachMessageRequest(String sessionId, String message) {}
public record AgentRequestPayload(

这里的设计意思是:

  • Web 到 Spring:只允许 CoachMessageRequest,也就是 sessionIdmessage
  • Spring 到 Python:使用 AgentRequestPayload,里面才包含后端补齐的 userId/profile/recentCheckins/mode

这就是前后端信任边界。

阶段 4:Python Agent 每轮重建上下文

4.1 这一阶段要解决什么

Spring 后端已经把可信上下文传给 Python Agent,但 Python Agent 还要把它变成模型能读懂的内容。

这一章的核心不是把所有历史消息拼进去,而是把"本轮决策需要的信息"整理成一个 JSON 字符串。

当前上下文字段包括:

  • user_message:用户本轮输入。
  • profile:后端查出的用户档案。
  • recent_checkins:后端查出的最近打卡。
  • rag_citations:RAG 检索到的引用片段。
  • required_behavior:本轮回答必须遵守的行为要求。

4.2 理解片段,不要复制 当前 _build_user_context() 源码片段

下面这段来自 services/agent-service/app/service.py,用于理解,不要单独覆盖整个文件。

python 复制代码
def _build_user_context(self, request: AgentRequest, citations) -> str:  # type: ignore[no-untyped-def]
    return json.dumps(
        {
            "user_message": request.message,
            "profile": request.profile.model_dump() if request.profile else None,
            "recent_checkins": [item.model_dump() for item in request.recentCheckins],
            "rag_citations": [item.model_dump() for item in citations],
            "required_behavior": "判断信息是否足够;低风险才给方向;高风险必须拒绝或降级。",
        },
        ensure_ascii=False,
    )

4.3 代码分段解释

第一段:

python 复制代码
"user_message": request.message,

这是用户本轮说的话。它不是历史消息列表,而是当前这一轮的输入。

第二段:

python 复制代码
"profile": request.profile.model_dump() if request.profile else None,

如果用户已经建档,就把 Pydantic 模型转成普通 dict。没有建档就传 None

这样模型可以根据是否有档案决定"直接建议"还是"先追问缺失信息"。

第三段:

python 复制代码
"recent_checkins": [item.model_dump() for item in request.recentCheckins],

这里把最近打卡转成列表。比如睡眠、疲劳、疼痛、体重等。

这些信息让 Coach Chat 能理解用户今天的状态,而不是只看一句孤立消息。

第四段:

python 复制代码
"rag_citations": [item.model_dump() for item in citations],

RAG 引用是资料证据。后续第 07 章会重点讲 RAG,这里先理解为"可引用的外部资料片段"。

第五段:

python 复制代码
"required_behavior": "判断信息是否足够;低风险才给方向;高风险必须拒绝或降级。",

这是本轮行为要求。它提醒模型:

  • 信息不足时要追问。
  • 低风险才给方向。
  • 高风险必须拒绝或降级。

这不是完整 Guardrails。真正的安全拦截仍然在 guardrails.py 和 AgentService 的短路逻辑里。这里是给模型看的行为提示。

第六段:

python 复制代码
ensure_ascii=False

它让 JSON 字符串保留中文,而不是把中文转成 \\u4f60\\u597d 这种形式。这样 trace 和调试输出更容易读。

4.4 执行命令 轻量 smoke:直接构造上下文

执行目录:Python Agent Service 目录。

bash 复制代码
cd services/agent-service
PYTHONPATH=. python - <<'PY'
import json
from app.schemas import AgentRequest, DailyCheckin, UserProfile
from app.service import AgentService

request = AgentRequest(
    userId="user-1",
    sessionId="session-1",
    message="那今天膝盖疼还能练腿吗?",
    profile=UserProfile(
        goal="减脂但不掉力量",
        heightCm=175,
        weightKg=80,
        weeklyTrainingDays=4,
        injuryHistory=["膝盖偶发疼痛"],
    ),
    recentCheckins=[
        DailyCheckin(date="2026-06-13", sleepHours=5.5, fatigueLevel=7, painLevel=7)
    ],
)

context = AgentService._build_user_context(None, request, [])
data = json.loads(context)

print(data["user_message"])
print(data["profile"]["goal"])
print(data["recent_checkins"][0]["painLevel"])
print(data["required_behavior"])
PY

预期输出类似:

text 复制代码
那今天膝盖疼还能练腿吗?
减脂但不掉力量
7
判断信息是否足够;低风险才给方向;高风险必须拒绝或降级。

这一步不需要 DeepSeek key,也不会调用真实模型。它只验证上下文组装函数本身。

如果失败,先检查:

  • ModuleNotFoundError: app:是否在 services/agent-service 目录运行,命令前是否有 PYTHONPATH=.
  • KeyError: required_behavior:说明 _build_user_context() 片段落后于当前源码。
  • ValidationError:检查 DailyCheckinUserProfile 字段名是否拼写正确。

4.5 执行命令 Python service 测试

执行目录:Python Agent Service 目录。

bash 复制代码
cd services/agent-service
PYTHONPATH=. pytest tests/test_service.py

预期输出类似:

text 复制代码
tests/test_service.py ......                                             [100%]

这一步证明 AgentService 的核心分支仍然通过测试。

注意:普通测试不会调用真实 DeepSeek。它主要验证 Guardrails、Today 降级、fake client 分支等基础逻辑。

阶段 5:本章最终验证

5.1 执行命令 一次性静态检查本章关键点

执行目录:项目根目录。

bash 复制代码
cd /Users/aibu/Aibu_System/Work_Projects/codex-template
rg -n "sessionId: coachResponse\\?\\.sessionId|business\\.listCheckins\\(userId, 7\\)|_build_user_context|required_behavior|recent_checkins|rag_citations" apps/web/src/App.tsx services/backend/src/main/java/com/aibu/coachagent/business/CoachController.java services/agent-service/app/service.py

预期输出要覆盖三类文件:

  • apps/web/src/App.tsx
  • CoachController.java
  • service.py

如果某一类文件没有输出,说明那一段链路可能没复制完整。

5.2 执行命令 三端基础验证

执行目录:分别进入对应目录运行。

bash 复制代码
cd apps/web
npm test
bash 复制代码
cd services/backend
./gradlew compileJava --no-daemon
bash 复制代码
cd services/agent-service
PYTHONPATH=. pytest tests/test_service.py

预期结果:

  • Web 测试通过。
  • 后端编译 BUILD SUCCESSFUL
  • Python service 测试通过。

这三个命令分别验证:

  • Web 工程没有被破坏。
  • Spring 的 Controller、DTO、AgentClient 装配能编译。
  • Python AgentService 的核心逻辑仍然通过测试。

5.3 联调前准备:确认 $TOKEN

后面的 curl 需要登录 token。

如果你已经在第 03 章拿过 $TOKEN,可以直接使用。

如果当前终端没有 $TOKEN,先回到第 03 章注册或登录。也可以用下面命令登录已有账号:

bash 复制代码
TOKEN=$(curl -s http://localhost:8080/api/auth/login \
  -H 'Content-Type: application/json' \
  -X POST \
  -d '{"email":"student@example.com","password":"coach-agent-demo"}' \
  | python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')
echo "$TOKEN"

如果返回登录失败,说明账号还不存在。先注册:

bash 复制代码
curl -s http://localhost:8080/api/auth/register \
  -H 'Content-Type: application/json' \
  -X POST \
  -d '{"email":"student@example.com","password":"coach-agent-demo","displayName":"课程学员"}' \
  | python3 -m json.tool

然后再执行登录命令。

5.4 执行命令 无真实 LLM key 的低成本验证:高风险短路也复用 sessionId

这一组命令适合没有配置 DeepSeek key 或不想消耗预算时使用。

原因是高风险输入会被确定性 Guardrails 拦截,不需要进入主模型调用。

执行目录:任意目录都可以。

bash 复制代码
curl -s http://localhost:8080/api/coach/chat \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -X POST \
  -d '{"message":"我想两周瘦10kg,每天只吃500大卡。"}' \
  | tee /tmp/coach-first.json \
  | python3 -m json.tool

SESSION_ID=$(python3 -c 'import json; print(json.load(open("/tmp/coach-first.json"))["sessionId"])')
echo "$SESSION_ID"

curl -s http://localhost:8080/api/coach/chat \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -X POST \
  -d "{\"sessionId\":\"$SESSION_ID\",\"message\":\"那我每天300大卡是不是更快?\"}" \
  | tee /tmp/coach-second.json \
  | python3 -m json.tool

python3 - <<'PY'
import json
first = json.load(open("/tmp/coach-first.json"))
second = json.load(open("/tmp/coach-second.json"))
print(first["sessionId"])
print(second["sessionId"])
print("same_session:", first["sessionId"] == second["sessionId"])
print("risk:", first["riskLevel"], second["riskLevel"])
PY

预期输出要看到:

text 复制代码
same_session: True
risk: high high

这一步证明:

  • 第一轮没有传 sessionId 时,Agent 生成了新 sessionId
  • 第二轮带回 sessionId 后,响应继续使用同一个 sessionId
  • 高风险输入被 Guardrails 拦截,不需要真实模型 key。

如果失败,先检查:

  • 401 Unauthorized$TOKEN 不存在或已过期,重新登录。
  • Connection refused:后端或 Python Agent Service 没启动。
  • 第二次 same_session: False:检查第二个 curl 里是否正确传了 sessionId

5.5 执行命令 有 DeepSeek key 的普通 Coach Chat 验证

这一组命令会触发真实 Agent 链路,低风险输入会经过 LLM-as-Judge 和主模型调用,可能消耗少量预算。

只有在你已经完成 scripts/bootstrap_secrets.sh 并启动完整服务后再运行。

bash 复制代码
curl -s http://localhost:8080/api/coach/chat \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -X POST \
  -d '{"message":"我想减脂但不想掉力量。"}' \
  | tee /tmp/coach-normal-first.json \
  | python3 -m json.tool

SESSION_ID=$(python3 -c 'import json; print(json.load(open("/tmp/coach-normal-first.json"))["sessionId"])')

curl -s http://localhost:8080/api/coach/chat \
  -H "Authorization: Bearer $TOKEN" \
  -H 'Content-Type: application/json' \
  -X POST \
  -d "{\"sessionId\":\"$SESSION_ID\",\"message\":\"那今天膝盖疼还能练腿吗?\"}" \
  | tee /tmp/coach-normal-second.json \
  | python3 -m json.tool

python3 - <<'PY'
import json
first = json.load(open("/tmp/coach-normal-first.json"))
second = json.load(open("/tmp/coach-normal-second.json"))
print("same_session:", first["sessionId"] == second["sessionId"])
print("first_trace:", first["traceId"])
print("second_trace:", second["traceId"])
PY

预期输出:

text 复制代码
same_session: True
first_trace: ...
second_trace: ...

如果模型返回的是追问,而不是完整计划,这是正常的。因为当前 Agent 会判断信息是否足够,信息不足时应该先追问。

本章常见报错与修复

1. 第二轮产生了新的 sessionId

先检查前端或 curl 是否真的把第一轮的 sessionId 带回去了。

curl 场景下重点看这一行:

bash 复制代码
-d "{\"sessionId\":\"$SESSION_ID\",\"message\":\"...\"}"

如果 $SESSION_ID 是空字符串,说明前一步解析 /tmp/coach-first.json 失败。

2. 401 Unauthorized

说明后端没有收到有效 JWT。

检查:

bash 复制代码
echo "$TOKEN"

如果没有输出,重新登录获取 token。

3. Connection refused

说明服务没有启动。

本章 curl 需要至少启动:

  • PostgreSQL
  • Redis
  • Python Agent Service
  • Spring Backend

如果你还没启动完整服务,回到第 03 章的 Compose 联调部分。

4. 普通 Coach Chat 返回 502

普通低风险聊天会进入真实模型链路。502 常见原因是:

  • Python Agent Service 没启动。
  • DeepSeek key 没有通过 secret 文件配置。
  • 网络无法访问 DeepSeek API。

如果你只是想验证 sessionId 复用,先用本章的高风险短路验证命令,不需要真实模型 key。

5. Agent "忘记档案"

先不要怀疑模型,先查后端有没有把档案传过去。

检查 CoachController

java 复制代码
var profile = business.getProfile(userId, displayName(userId)).orElse(null);

再检查是否调用了:

java 复制代码
business.listCheckins(userId, 7)

如果 profile 本身还没保存,先回到第 03 章调用 PUT /api/profile 建档。

6. 学生误以为本章已经实现聊天历史

要明确告诉自己:

当前阶段没有完整聊天历史落库回放。

sessionId 的作用是让多轮请求属于同一个会话,并作为 trace 查询线索。模型每轮看到的上下文,主要来自后端重新组装的 profilerecentCheckinsrag_citations 和本轮 message

完整消息历史、长期记忆、记忆污染防护,会在后续章节继续展开。

7. GET /api/coach/sessions/{sessionId} 查不到聊天记录

这是预期边界。

当前接口:

java 复制代码
return agentClient.trace(sessionId);

它代理的是 trace,不是聊天历史表。

不要把这个接口当作完整聊天记录查询。

本章验收清单

完成本章后,你应该能确认:

  • 你能说清楚:模型不会自动记住上一轮。
  • 你能说清楚:当前短期记忆是 sessionId 连续性 + 每轮可信上下文重建。
  • Web 发送 Coach 消息时只传 messagesessionId
  • Spring 后端从 Authentication 取当前用户,而不是相信前端传 userId
  • Spring 后端会查询 profile 和最近 7 条 checkin。
  • Python _build_user_context() 包含 user_messageprofilerecent_checkinsrag_citationsrequired_behavior
  • ./gradlew compileJava --no-daemon 通过。
  • PYTHONPATH=. pytest tests/test_service.py 通过。
  • 高风险短路 curl 可以验证两轮响应复用同一个 sessionId
  • 你不会把本章讲成完整聊天历史、完整长期记忆或完整消息落库系统。

下一章衔接

这一章我们解决的是一轮会话内的上下文连续性。

下一章会进一步讨论长期记忆和用户隔离:哪些事实应该落进数据库,哪些表必须带 user_id,为什么多用户 Agent 不能让记忆互相污染。

相关推荐
爱卜大王2 小时前
第 04 章:工具调用:让模型只提需求,服务端执行确定性工具
agent
爱卜大王2 小时前
第 02 章:用户建档系统:从 0 搭出可信用户后端
agent
tangzzzfan2 小时前
如何写好一个 Skill:划分、结构与实践
agent·workflow
qcx234 小时前
提示工程已死,指令架构永生:深度复盘 GPT-5.5 与 Claude 4.7 带来的范式转移
人工智能·ai·llm·agent·agi·harness
HIT_Weston4 小时前
116、【Agent】【OpenCode】项目配置(SemVer)(补充)
人工智能·agent·opencode
Nile4 小时前
解密Palantir系列二:4.Palantir Foundry:七问判断该不该上
人工智能·ai·agent·ai编程·ai-native
FAREWELL000754 小时前
CC-Switch的安装和使用
ai·agent·claude code 配置
copyer_xyf13 小时前
LangChain 调用 LLM
后端·python·agent
李燚13 小时前
Graph 编排:不只是 ReAct 的通用 DAG
agent·workflow·graph·ai-agent·dag