【源码笔记-Agentscope】智能体创建(中)
AgentBase
根据类说明给出的几个核心设计原则来看看具体是怎么实现的。
- 职责分离:AgentBase 提供基础设施支持(钩子、订阅、中断、状态),但不包含业务逻辑
- 内存管理委托:将内存管理职责交给需要它的具体智能体(例如 ReActAgent)
- 状态管理:通过实现 StateModule 接口来管理状态
- 中断机制:使用响应式模式,子类在适当的检查点调用
checkInterruptedAsync(),通过 Mono 链传播InterruptedException- 观察模式:智能体可以接收消息而无需生成回复
职责分离
核心思想: AgentBase 只提供基础设施,不包含具体的业务逻辑。
源码实现:
- 抽象方法定义业务边界
1 | // 第 275 行:只定义接口,不实现具体逻辑 |
- 最终方法提供基础设施
1 | // 第 166 行:call() 方法是 final 的,提供统一的执行框架 |
- Hook 机制支持扩展
1 | // 第 95-96 行:支持实例级和系统级 Hook |
设计优势:
✅ 可测试性:基础设施代码统一测试,业务逻辑单独测试
为什么能够统一测试基础设施代码?
所有通用逻辑都在 AgentBase 中实现
1
2
3
4
5// AgentBase 中的最终方法(final)- 所有子类共享同一套实现
public final Mono<Msg> call(List<Msg> msgs) // 统一调用流程
private Mono<List<Msg>> notifyPreCall(List<Msg> msgs) // 统一 Hook 通知
private Mono<Msg> notifyPostCall(Msg finalMsg) // 统一后置处理
private Function<Throwable, Mono<Msg>> createErrorHandler() // 统一错误处理测试用例只需写一次
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34// 测试 AgentBase 的基础设施(一个测试类覆盖所有 Agent)
class AgentBaseTest {
void testHookMechanism() {
// 测试 Hook 是否正确被调用
MockAgent agent = new MockAgent();
agent.addHook(mockHook);
agent.call(msgs).block();
verify(mockHook).onEvent(any(PreCallEvent.class)); // ✅ Hook 机制正确
}
void testInterruptMechanism() {
// 测试中断是否正确传播
MockAgent agent = new MockAgent();
agent.interrupt();
StepVerifier.create(agent.call(msgs))
.expectError(InterruptedException.class); // ✅ 中断机制正确
}
void testRunningStateCheck() {
// 测试并发保护
MockAgent agent = new MockAgent();
Mono<Msg> call1 = agent.call(msgs);
Mono<Msg> call2 = agent.call(msgs); // 应该抛出异常
StepVerifier.create(call2)
.expectErrorMessage("Agent is still running") // ✅ 状态检查正确
.verify();
}
}业务逻辑可以独立Mock 测试
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20// 测试具体业务逻辑时,不需要关心 Hook、中断等基础设施
class ReActAgentBusinessLogicTest {
void testReasoningLogic() {
// 只关注 doCall() 的业务逻辑
ReActAgent agent = Mockito.spy(new ReActAgent());
// Mock 掉基础设施依赖
Mockito.doReturn(Mono.empty())
.when(agent).checkInterruptedAsync(); // 不真正检查中断
Mono<Msg> result = agent.doCall(testMsgs);
// 只验证业务逻辑:推理→工具调用→结果生成
StepVerifier.create(result)
.assertNext(msg -> assertThat(msg.getTextContent())
.contains("工具调用结果"))
.verifyComplete();
}
}分层测试策略
1
2
3
4
5
6
7
8测试金字塔:
/ \
/ 集成测试 \ ← 端到端测试(完整 Agent 集成)
/-----------\
/ 业务逻辑测试 \ ← 只测试 doCall() 的具体实现
/----------------\
/ 基础设施单元测试 \ ← 测试 AgentBase 的通用机制
/____________________\
✅ 可维护性:修改 Hook 机制不影响业务逻辑
为什么修改 Hook 机制不会影响业务逻辑?
清晰的边界划分
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24// AgentBase 负责:Hook 管理(基础设施层)
public abstract class AgentBase {
private final List<Hook> hooks; // Hook 列表
private Mono<List<Msg>> notifyPreCall(List<Msg> msgs) {
// Hook 的实现细节 - 业务层不关心
for (Hook hook : getSortedHooks()) {
result = result.flatMap(hook::onEvent);
}
return result.map(PreCallEvent::getInputMessages);
}
}
// ReActAgent 负责:业务逻辑(业务层)
public class ReActAgent extends AgentBase {
protected Mono<Msg> doCall(List<Msg> msgs) {
// 完全不关心 Hook 如何工作
// 只需要实现:推理 → 工具调用 → 返回结果
return reasoning(msgs)
.flatMap(toolCall -> executeTool(toolCall))
.map(result -> buildResponse(result));
}
}实际案例:如果要优化 Hook 执行顺序
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17// 场景:发现 Hook 执行顺序有问题,需要优化
// 【修改前】AgentBase.java
private void sortHooks() {
this.hooks.sort(Comparator.comparingInt(Hook::priority));
}
// 【修改后】AgentBase.java - 改为稳定排序
private void sortHooks() {
this.hooks.sort(Comparator
.comparingInt(Hook::priority)
.thenComparingInt(hooks::indexOf)); // 保持注册顺序
}
// ✅ ReActAgent.java 完全不需要修改!
// ✅ UserAgent.java 完全不需要修改!
// ✅ 所有子类都不需要修改!反向案例:如果职责不分离会怎样?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25// ❌ 反面教材:混在一起的设计
public class BadReActAgent {
public Mono<Msg> call(List<Msg> msgs) {
// 业务逻辑和基础设施混在一起
// 1. 检查运行状态(手动实现)
if (isRunning) throw new Exception();
// 2. 通知 Hook(每个 Agent 都要写一遍)
for (Hook hook : hooks) hook.onPreCall(msgs);
// 3. 业务逻辑
Msg result = reasoning(msgs);
// 4. 再次通知 Hook
for (Hook hook : hooks) hook.onPostCall(result);
// 5. 错误处理(每个地方都不一样)
try { ... } catch(Exception e) { ... }
}
}
// 后果:
// - 修改 Hook 机制?→ 需要改所有 Agent 类!
// - 发现 Bug?→ 每个类都要检查一遍!
// - 新增功能?→ 每个类都要加一遍代码!维护成本对比
变更类型 职责分离设计 混合设计 修改 Hook 执行顺序 改 1 处 (AgentBase) 改 N 处 (所有 Agent) 修复中断 Bug 改 1 处 改 N 处 新增 Hook 类型 改 1 处 改 N 处 添加新的基础设施功能 改 1 处 改 N 处 + 容易遗漏
✅ 可扩展性:子类只需关注 doCall() 的具体实现
为什么子类可以如此专注?
框架已经铺好了路
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19// ReActAgent 只需要写:
public class ReActAgent extends AgentBase {
protected Mono<Msg> doCall(List<Msg> msgs) {
// 纯粹的业务逻辑 - 一行基础设施代码都没有!
return reasoning(msgs) // 思考
.flatMap(this::chooseTool) // 选择工具
.flatMap(this::executeTool) // 执行工具
.flatMap(this::buildResponse); // 构建回答
}
}
// 不需要关心:
// ❌ 如何管理运行状态
// ❌ 如何通知 Hook
// ❌ 如何处理中断
// ❌ 如何广播给订阅者
// ❌ 如何追踪日志
// → AgentBase 全都包办了!对比:如果没有这种设计
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37// ❌ 没有框架的情况下
public class RawAgent {
private AtomicBoolean running = new AtomicBoolean(false);
private List<Hook> hooks = new ArrayList<>();
public Mono<Msg> call(List<Msg> msgs) {
// 每次都要写一遍模板代码
if (!running.compareAndSet(false, true)) {
throw new IllegalStateException("Agent is running");
}
// 通知 Hook(容易忘记)
PreCallEvent event = new PreCallEvent(this, msgs);
for (Hook hook : hooks) {
hook.onEvent(event).block(); // 还容易写错
}
try {
// 真正的业务逻辑(被埋在 50 行模板代码下面)
Msg result = doWork(msgs);
// 通知 Hook(又容易忘记)
for (Hook hook : hooks) {
hook.onEvent(new PostCallEvent(this, result)).block();
}
return Mono.just(result);
} finally {
running.set(false); // 别忘了放在 finally 里
}
}
}
// 结果:
// - 80% 的代码是重复的模板
// - 容易出错(忘记通知 Hook、忘记释放锁)
// - 难以阅读(业务逻辑被淹没)实际案例对比
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50// ===== 使用 Agentscope 框架 =====
public class ReActAgent extends AgentBase {
protected Mono<Msg> doCall(List<Msg> msgs) {
// 100% 专注于业务
return Mono.justOrEmpty(msgs)
.flatMap(this::reasoningStep)
.repeatWhen(completed -> completed.take(maxIterations));
}
// 代码行数:~5 行核心逻辑
// 心智负担:0(不用管基础设施)
}
// ===== 不使用框架 =====
public class ManualReActAgent {
private final ExecutorService executor = Executors.newCachedThreadPool();
private final Semaphore lock = new Semaphore(1);
public CompletableFuture<Msg> call(List<Msg> msgs) {
return CompletableFuture.supplyAsync(() -> {
// 手动管理锁
if (!lock.tryAcquire()) {
throw new ConcurrentModificationException();
}
try {
// 手动管理状态
if (msgs == null || msgs.isEmpty()) {
throw new IllegalArgumentException();
}
// 手动通知监听器
listeners.forEach(l -> l.onPreCall(this, msgs));
// 终于能写业务逻辑了...
Msg result = reasoning(msgs);
// 又要手动通知
listeners.forEach(l -> l.onPostCall(this, result));
return result;
} finally {
lock.release(); // 千万别忘!
}
}, executor);
}
// 代码行数:~30 行模板 + 5 行业务
// 心智负担:重(要记住所有细节)
}扩展新 Agent 的难度对比
设计模式 开发新 Agent 步骤 代码量 易错点 职责分离 1.继承 AgentBase
2.实现 doCall()
3.实现 handleInterrupt()~20 行 几乎无 混合设计 1.手动管理状态
2.手动管理锁
3.手动通知 Hook
4.手动错误处理
5.写业务逻辑~100 行 容易遗漏
总结
这三个优势是相辅相成的:
1 | 职责分离 |
核心价值:
- 🎯 降低认知负担:不用同时理解基础设施和业务逻辑
- 🔧 提高开发效率:复用已有代码,专注创新部分
- 🛡️ 减少 Bug:边界清晰,错误容易定位
- 📈 便于演进:框架升级不影响业务代码
内存管理委托
核心思想: AgentBase 不负责记忆管理,交给具体子类(如 ReActAgent)实现。
源码体现:
1 | // 第 49-50 行:类注释明确说明 |
为什么这样设计?
不同场景需要不同的记忆策略
- 简单问答 Agent:可能只需要短期记忆
- ReAct Agent:需要完整的历史对话 + 工具调用记录
- RAG Agent:需要外部向量数据库 + 检索机制
避免过度设计
- 如果 AgentBase 实现通用记忆管理,会导致接口复杂化
- 增加不必要的性能开销
灵活性
1
2
3
4
5// ReActAgent 可以根据自己的需要实现:
// - 滑动窗口记忆
// - 重要性过滤
// - 摘要压缩
// - 外部存储
状态管理
核心思想: 通过实现 StateModule 接口来管理状态。
源码实现:
1 | // 第 88 行:类声明直接实现 StateModule |
StateModule 接口的能力:
- 保存/加载状态到 Session
- 支持智能体的持久化
- 为分布式部署提供基础
中断机制
核心思想: 使用响应式模式,在适当检查点传播 InterruptedException。
源码实现:
- 中断状态管理
1 | // 第 100-101 行:中断标志和用户消息 |
- 设置中断
1 | // 第 320-336 行:两种中断方法 |
- 检查中断(响应式)
1 | // 第 363-370 行:异步检查中断 |
- 子类使用示例
1 | // 第 354-359 行:注释中给出的使用示例 |
- 异常处理
1 | // 第 402-410 行:错误处理器 |
设计亮点:
- ✅ 非阻塞:中断检查也是响应式的,不阻塞执行流
- ✅ 优雅退出:给子类机会清理状态、生成摘要
- ✅ 灵活控制:子类决定在哪里检查中断
观察模式
核心思想: 智能体可以接收消息而无需生成回复。
源码实现:
- 默认空实现
1 | // 第 438-440 行:doObserve 默认为空 |
- 公共 API
1 | // 第 633-650 行:observe 方法 |
- 广播机制
1 | // 第 615-623 行:向订阅者广播消息 |
- 订阅者管理
1 | // 第 97 行:存储订阅关系 |
应用场景:
- 📡 多智能体协作:监控其他智能体的行为
- 🔍 审计日志:记录对话流程
- 🧠 共享上下文:构建多智能体的共同知识
- ⚙️ 流水线模式:上游输出作为下游输入
总结
通过源码分析可以看到,AgentBase 的设计完美体现了这五个核心原则:
| 原则 | 实现方式 | 关键代码 |
|---|---|---|
| 职责分离 | 抽象方法 + final 方法 | doCall(), call() |
| 内存管理委托 | 注释明确说明,不实现 | 类注释 L49-50 |
| 状态管理 | 实现 StateModule 接口 | implements StateModule |
| 中断机制 | 响应式中断检查 | checkInterruptedAsync() |
| 观察模式 | 默认空实现 + 广播机制 | doObserve(), broadcastToSubscribers() |
这种设计使得 Agentscope 既保持了框架的统一性,又为具体实现提供了充分的灵活性!
StructuredOutputCapableAgent
从名字上来看,这是一个在 AgentBase上做了结构化输出的继承拓展。
从它的字段上来看,引入了toolkit和structuredOutput
还是先引入一下官方的类说明:
StructuredOutputCapableAgent 类说明:
支持结构化输出生成的抽象基类。
该类提供了基于
generate_response工具模式结合StructuredOutputHook流程控制来生成结构化输出的基础设施。核心特性:
- 自动工具注册:为结构化输出自动注册必要的工具
- 模式验证:在工具执行前进行 schema 验证
- 内存压缩:在结构化输出完成后进行记忆压缩
- 可配置的提醒模式:支持 TOOL_CHOICE(工具选择)或 PROMPT(提示)两种模式
子类要求:
- 提供 Toolkit:通过构造函数传递工具包
- 实现 getMemory():提供记忆访问能力
- 实现 buildGenerateOptions():构建模型配置选项
结构化输出作为一个拓展性的能力我觉得完全 OK,但是原来工具是不会集成在基类中的,而是作为一个拓展出来的能力所存在。Toolkit这里就先不展开,之后再专门开一章研究一下工具具体是怎么实现的。
其次来看另外一个字段
1 | protected final StructuredOutputReminder structuredOutputReminder; |
提供了TOOL_CHOICE和PROMPT两种枚举
StructuredOutputReminder 枚举详解
这个枚举定义了如何确保模型在结构化输出模式下调用 generate_response 工具的策略。
TOOL_CHOICE 模式(推荐)
核心机制:使用 API 的 tool_choice 参数来强制模型调用工具
工作流程:
- 第一轮:模型可以自由调用任何工具(包括业务工具)
- 如果模型没有调用
generate_response:下一轮会通过tool_choice参数强制它调用
优势:
- ✅ 允许代理先完成多步骤任务
- ✅ 最后再生成结构化输出
- ✅ 更灵活、更高效
代码逻辑示意:
1 | 第一轮:自由调用 → 可能调用业务工具 |
PROMPT 模式(传统方式)
核心机制:通过注入提示词来提醒模型调用工具
工作流程:
- 如果模型没有调用
generate_response工具 - 在对话中注入一条提醒消息
- 代理重新进行推理
- 可能需要多次迭代才能成功
劣势:
- ❌ 需要多次 API 调用和重试
- ❌ 效率较低
代码逻辑示意:
1 | 推理 → 未调用 generate_response |
对比总结
| 特性 | TOOL_CHOICE | PROMPT |
|---|---|---|
| 控制方式 | API 参数强制 | 文本提示提醒 |
| 灵活性 | 高(允许多步骤) | 低 |
| 效率 | 高(少次迭代) | 低(可能多次重试) |
| API 调用次数 | 较少 | 较多 |
| 推荐度 | ✅ 默认推荐 | 传统方式 |
形象比喻:
- TOOL_CHOICE:像考试时的”必须交答题纸”要求——你可以先在草稿纸上演算(调用其他工具),但最后必须把答案填到答题纸上(generate_response)
- PROMPT:像老师不断口头提醒”要交卷了”——学生可能还需要多次提醒才会提交
关键问题:加载其他工具包时的调用路径
问题:如果给 Agent 加载了其他业务工具包,调用路径是否一致?
答案:调用路径一致,但工具选择范围不同。分两种情况讨论:
情况 1:仅使用 generate_response 工具(演示案例)
1 | 模型推理 → 只能调用 generate_response → 生成结构化输出 |
这是最简单的场景,模型没有其他选择,必须调用 generate_response。
情况 2:加载了其他业务工具包
1 | // 示例:添加了搜索工具和计算工具 |
调用路径(TOOL_CHOICE 模式):
1 | 第一轮(自由选择): |
关键点:
- 多轮对话能力:TOOL_CHOICE 允许模型先调用业务工具完成任务,最后再输出
- 最终收敛:无论中间调用多少工具,最终都会被强制调用
generate_response - 职责分离:业务工具负责执行具体任务,
generate_response负责格式化输出
调用路径对比表
| 场景 | 可用工具 | 调用路径 | 轮次 |
|---|---|---|---|
| 仅 generate_response | 只有 generate_response |
直接调用 | 1 轮 |
| 有其他业务工具 | 业务工具 + generate_response |
先调用业务工具 → 强制调用 generate_response |
2 轮或更多 |
| PROMPT 模式 | 任意工具 | 可能需要多次注入提示 | N 轮(低效) |
实际执行示例:
1 | // 用户提问:"查询北京天气并总结" |
设计哲学:模型自主决策 + 框架强制收敛
核心机制总结:
是否调用业务工具取决于基础模型自身的判断,而结构化输出能力通过 API 参数在最后阶段强制模型调用
generate_response工具以确保输出格式化。
这个设计体现了两层控制逻辑:
1️⃣ 第一层:模型自主决策(自由度)
- 决策主体:基础大模型(LLM)
- 决策依据:根据用户输入、上下文语义、任务复杂度自动判断
- 决策内容:是否需要调用业务工具?调用哪些工具?
- 控制方式:无强制干预,模型自由选择
1 | graph LR |
2️⃣ 第二层:框架强制收敛(约束力)
- 触发条件:检测到模型未调用
generate_response - 执行机制:通过
tool_choiceAPI 参数强制指定 - 目标:确保结构化输出格式统一
- 控制方式:API 级别的技术强制
1 | graph TB |
3️⃣ 两层控制的协同效应
| 维度 | 模型自主决策 | 框架强制收敛 |
|---|---|---|
| 目的 | 灵活应对复杂任务 | 保证输出格式统一 |
| 时机 | 任务执行过程中 | 任务完成后输出前 |
| 手段 | 模型自由推理 | API 参数强制 |
| 灵活性 | 高(可多步骤) | 低(必须调用) |
| 角色定位 | 过程导向 | 结果导向 |
形象比喻:
就像公司项目管理:
- 模型自主决策:员工可以自由选择使用什么工具和方法完成任务(调研、计算、分析等)
- 框架强制收敛:提交报告时必须使用公司统一的 PPT 模板(
generate_response)这样既保证了工作灵活性,又确保了汇报标准化。
4️⃣ 技术实现要点
1 | // 伪代码展示控制流程 |
关键结论:
✅ 业务工具调用是概率性的 —— 由模型根据任务需求自主决定
✅ 结构化输出是确定性的 —— 无论中间过程如何,最终必然收敛到 generate_response
✅ TOOL_CHOICE 是技术性强制 —— 不依赖模型配合,直接通过 API 参数实现
✅ PROMPT 是语言性引导 —— 通过文本提示期望模型配合,但可能需多次迭代
1 | // 实际使用示例 |
而且我发现,在Agentscope中,结构化输出是通过代码生成了一个临时工具类
1 | /** |
匿名内部类实现:临时工具的精妙设计
这个工具的实现非常巧妙,采用了匿名内部类的方式动态创建一个临时的 AgentTool。让我们分解它的核心逻辑:
1️⃣ 工具定义(L884-905)
1 | private AgentTool createStructuredOutputTool(...) { |
关键点:
- ✅ 动态性:schema 是外部传入的参数,可以为不同的结构化输出需求生成不同的工具
- ✅ 一次性:每次创建都是新的匿名类实例,用完即弃
- ✅ 标准化:名称固定为
generate_response,确保后续能被TOOL_CHOICE强制调用
2️⃣ 工具执行(L908-949)
1 | public Mono<ToolResultBlock> callAsync(ToolCallParam param) { |
执行流程图:
1 | graph LR |
3️⃣ 设计亮点分析
| 特性 | 实现方式 | 优势 |
|---|---|---|
| 临时性 | 匿名内部类 | 无需单独定义类,按需创建 |
| 动态 Schema | 参数透传 | 支持任意类型的结构化输出 |
| 职责单一 | 只负责存储和转换 | 验证由 ToolExecutor 处理,符合单一职责 |
| 响应式 | 返回 Mono<ToolResultBlock> |
与 Agentscope 响应式架构一致 |
| 自描述 | 包含详细的 description | 帮助模型理解何时调用 |
4️⃣ 为什么采用匿名内部类?
对比两种方式:
1 | // ❌ 方案 1:定义独立类(不够灵活) |
优势总结:
- 代码量更少:不需要单独定义一个类
- 作用域明确:只在当前方法内有效,不会污染命名空间
- 闭包捕获:可以直接使用外部方法的参数(如
schema) - 语义清晰:一看就知道这是一个”临时工具”
5️⃣ 关键注释解读
1 | // The tool simply stores the raw data |
这两行注释揭示了 Agentscope 的设计哲学:
工具本身不验证,验证逻辑前置到执行器
这样设计的原因:
- ✅ 职责分离:工具只负责业务逻辑,验证由框架统一处理
- ✅ 性能优化:避免重复验证
- ✅ 统一管理:所有工具的验证规则集中管理
然后在 StructuredOutputHook 里发现了一个很有意思的方法:
1 | /** |
在这里是使用了UserMsg来充当了一个临时的SystemPrompt,很有意思,在我使用的过程中我用的是AssistantMsg,不知道这里使用UserMsg还是AssistantMsg会有什么不同。
深度解析:三种消息角色的语义差异与选择
这个问题触及了 Agent 对话设计的一个核心概念:消息角色的语义学。让我们从三个维度来分析:
1️⃣ 消息角色的本质含义
| 角色 | name | role | 语义定位 | 典型场景 |
|---|---|---|---|---|
| System | "system" |
SYSTEM |
系统级指令、规则制定者 | 初始化提示、行为约束 |
| User | "user" / "system" |
USER |
信息提供者、任务发起者 | 用户输入、外部数据注入 |
| Assistant | "assistant" / 工具名 |
ASSISTANT |
响应执行者、结果输出者 | 模型回复、工具调用结果 |
关键洞察:
- ✅
name字段:标识消息的来源身份(谁说的) - ✅
role字段:定义消息的对话角色(在对话中扮演什么角色) - ✅ 两者可以分离:
name="system"+role=USER是完全合法的组合
2️⃣ 为什么这里选择 role=USER?
代码分析:
1 | return Msg.builder() |
设计意图:
✅ 选择 USER 的原因
语义匹配:这是一个外部指令注入
1
2
3
4
5
6正常对话流程:
User: "查询天气"
Assistant: [调用工具]
System(作为 User): "请调用 generate_response"
→ 这相当于系统"扮演"用户在提醒模型预期:LLM 训练时的模式
1
2
3
4
5
6
7
8训练数据模式:
User → Assistant → User → Assistant
如果插入 Assistant 消息:
User → Assistant → [Assistant] ← 违反对话轮次预期
如果插入 User 消息:
User → Assistant → [User] ✓ 符合交替模式元认知位置:提醒是对话的一部分,不是背景设定
SYSTEM:设定对话规则和边界(一次性)USER:参与具体对话流程(多轮交互)
❌ 不使用 SYSTEM 的原因
1 | // ❌ 如果使用 SYSTEM |
问题分析:
- SYSTEM 消息通常在对话开始时设置一次
- 中途插入 SYSTEM 消息会显得突兀,破坏对话连贯性
- LLM 可能不会将 SYSTEM 消息视为”需要回应的请求”
❓ 如果使用 ASSISTANT 会怎样?
1 | // ❓ 如果使用 ASSISTANT(你的实践方式) |
潜在影响:
| 维度 | 效果 | 原因分析 |
|---|---|---|
| 对话流 | ⚠️ 混乱 | 连续两个 Assistant 消息违反对话轮次 |
| 模型理解 | ⚠️ 困惑 | “我自己提醒自己?” |
| 实际效果 | ⚪ 可能有效 | 但依赖于模型的容错能力 |
| 语义准确性 | ❌ 不准确 | 这不是助手的自主行为 |
3️⃣ 三种选择的对比实验
假设场景:模型第一轮没有调用 generate_response
方案 A:Agentscope 的选择(role=USER)✅
1 | // 第 1 轮 |
优势:
- ✅ 对话流畅自然
- ✅ 符合 LLM 训练模式
- ✅ 明确的外部指令信号
方案 B:使用 role=ASSISTANT ⚠️
1 | // 第 1 轮 |
问题:
- ❌ 违反了”一问一答”的对话模式
- ❌ 模型可能会困惑:”我为什么要接自己的话?”
方案 C:使用 role=SYSTEM ❌
1 | // 第 1 轮 |
问题:
- ❌ SYSTEM 消息通常只在开头出现
- ❌ 中途插入会破坏对话上下文的一致性
4️⃣ 为什么 name=”system” 而不是 name=”user”?
这是个精妙的设计:
1 | .name("system") // ← 真实来源:框架系统 |
元数据追踪:
1 | Map.of( |
这样设计的好处:
- 可追溯性:后续处理可以识别这是”系统生成的提醒”
- 调试友好:日志中清楚显示消息来源
- 避免混淆:不会误认为是真实用户的输入
5️⃣ 实战建议
推荐做法(与 Agentscope 保持一致):
1 | // ✅ 标准实现 |
如果你已经使用了 ASSISTANT:
1 | // 如果你的实现是这样的 |
可能的影响:
- ⚪ 短期:可能也能工作(LLM 有容错能力)
- ⚠️ 长期:可能导致对话模式混乱,特别是在复杂多轮对话中
- ✅ 建议:改为
role=USER以获得更好的语义一致性
6️⃣ 总结:消息角色的选择原则
| 场景 | 推荐 role | 理由 |
|---|---|---|
| 框架初始化指令 | SYSTEM |
设定对话基调和规则 |
| 外部指令注入 | USER |
模拟用户发起新请求 |
| 工具执行结果 | ASSISTANT |
表示助手的响应 |
| 模型自主回复 | ASSISTANT |
助手的自然输出 |
| 提醒/打断 | USER |
作为外部干预信号 |
核心原则:
消息角色应该反映其在对话流程中的功能定位,而不是消息的来源。
这就是为什么 Agentscope 选择用 name="system" + role=USER 的组合:既保持了来源的可追溯性,又确保了对话语义的正确性!