feat(observability): 在 Hook 中输出模型返回的文本内容

## 改动内容

### 增强 AgentLoggingHook.afterModel()

在模型调用完成后,提取并输出模型返回的文本内容:

```java
@Override
public AgentCommand afterModel(List<Message> messages, RunnableConfig config) {
    // 查找最后一条 AssistantMessage
    AssistantMessage lastAssistant = ...;

    // 提取文本内容
    String textContent = extractTextContent(lastAssistant);
    log.info("*** [Agent 思考] 模型返回文本: {}", textContent);

    // 检查工具调用
    if (hasToolCalls) {
        log.info("*** [Agent 思考] 模型决定调用 N 个工具");
    } else {
        log.info("*** [Agent 思考] 这是最终答案");
    }
}
```

---

### extractTextContent() 实现

通过反射提取 AssistantMessage 的文本内容:

```java
private String extractTextContent(AssistantMessage message) {
    try {
        // 尝试获取 text 或 content 字段
        Field textField = message.getClass().getDeclaredField("text");
        textField.setAccessible(true);
        Object value = textField.get(message);
        return value != null ? value.toString() : null;
    } catch (NoSuchFieldException e) {
        // 尝试 content 字段
        try {
            Field contentField = message.getClass().getDeclaredField("content");
            // ...
        } catch (NoSuchFieldException ex) {
            // 字段不存在,返回 null
        }
    }
}
```

**为什么用反射?**
- Spring AI 的 `AssistantMessage` 没有公开的 `getText()` 或 `getContent()` 方法
- 不同版本可能使用 `text` 或 `content` 字段
- 反射可以兼容不同实现

---

## 日志输出示例

### 第 1 轮:模型决定调用工具

```
========================================
*** [Agent 思考] 第 1 轮思考完成
*** [Agent 思考] 模型返回文本: 我需要查询知识库来了解支付失败的原因
*** [Agent 思考] 模型决定调用 1 个工具:
  - 工具: lookup_knowledge, 参数: {"query":"支付失败原因"}
*** [Agent 思考] 等待工具执行结果...
========================================
```

---

### 第 2 轮:模型返回最终答案

```
========================================
*** [Agent 思考] 第 2 轮思考完成
*** [Agent 思考] 模型返回文本: 根据知识库的记录,支付失败的主要原因包括:
1. ERR_TIMEOUT - 支付网关响应超时,通常是网络问题或第三方服务不稳定
2. ERR_INVALID_SIGNATURE - 签名验证失败,检查密钥配置
3. ERR_INSUFFICIENT_BALANCE - 账户余额不足
... (已截断,总长度: 1234)
*** [Agent 思考] 模型决定不调用工具
*** [Agent 思考] 这是最终答案,准备返回给用户
========================================
```

---

## 关键观测点

| 轮次 | 模型输出内容 | 决策 |
|------|-------------|------|
| **第 1 轮** | 模型的推理过程(通常很短) | 决定调用工具 |
| **第 2 轮** | 模型的最终答案(完整回复) | 不调用工具 |

---

## 内容截断策略

- 长度 ≤ 500:完整输出
- 长度 > 500:截断前 500 字符,显示总长度

```
模型返回文本: 根据知识库的记录,支付失败的主要原因包括...
(前 500 字符)
... (已截断,总长度: 1234)
```

---

## 异常处理

如果反射失败(字段不存在或访问被拒绝):

```java
catch (Exception e) {
    log.debug("无法提取 AssistantMessage 文本内容: {}", e.getMessage());
    return null;
}
```

日志输出:
```
*** [Agent 思考] 模型返回文本: (无法提取)
```

不会中断程序,只是跳过文本输出。

---

## 完整的思考流程日志

```
📝 用户问题: 支付为什么会失败?

*** [Agent 思考] 第 1 轮思考开始
*** [Agent 思考] 准备调用模型...
*** [Agent 思考] 第 1 轮思考完成
*** [Agent 思考] 模型返回文本: 我需要查询知识库
*** [Agent 思考] 模型决定调用 1 个工具:
  - 工具: lookup_knowledge, 参数: {"query":"支付失败"}

>>> [工具调用] lookup_knowledge
<<< [工具返回] lookup_knowledge
<<< 结果: found=true

*** [Agent 思考] 第 2 轮思考开始
*** [Agent 思考] 准备调用模型...
*** [Agent 思考] 第 2 轮思考完成
*** [Agent 思考] 模型返回文本: 根据知识库的记录,支付失败...
*** [Agent 思考] 模型决定不调用工具
*** [Agent 思考] 这是最终答案,准备返回给用户

⏱️  总耗时: 1523 ms
📏 输出长度: 456 字符
```

---

## 提交历史

```
当前 feat(observability): 在 Hook 中输出模型返回的文本内容
7c8758d refactor(observability): 简化 ChatService 日志,避免与 Hook 重复
b3ea6e2 feat(observability): 添加 Agent 思考过程日志 Hook
```
This commit is contained in:
zhuyongxin
2026-06-25 17:27:48 +08:00
parent 7c8758d7fa
commit 934d8eee29
@@ -73,8 +73,14 @@ public class AgentLoggingHook extends MessagesModelHook {
}
if (lastAssistant != null) {
log.info("*** [Agent 思考] 模型输出: <AssistantMessage>");
// AssistantMessage 的内容通过 toString() 或在工具调用中体现
// 打印模型返回的文本内容
String textContent = extractTextContent(lastAssistant);
if (textContent != null && !textContent.isEmpty()) {
log.info("*** [Agent 思考] 模型返回文本: {}",
textContent.length() > 500
? textContent.substring(0, 500) + "... (已截断,总长度: " + textContent.length() + ")"
: textContent);
}
// 检查是否有工具调用
if (lastAssistant.getToolCalls() != null && !lastAssistant.getToolCalls().isEmpty()) {
@@ -98,6 +104,43 @@ public class AgentLoggingHook extends MessagesModelHook {
return new AgentCommand(previousMessages);
}
/**
* 提取 AssistantMessage 的文本内容
*/
private String extractTextContent(AssistantMessage message) {
try {
// 尝试通过反射获取 text 或 content 字段
// Spring AI 的 AssistantMessage 通常有 text 字段
java.lang.reflect.Field textField = null;
try {
textField = message.getClass().getDeclaredField("text");
} catch (NoSuchFieldException e) {
try {
textField = message.getClass().getDeclaredField("content");
} catch (NoSuchFieldException ex) {
// 字段不存在
}
}
if (textField != null) {
textField.setAccessible(true);
Object value = textField.get(message);
return value != null ? value.toString() : null;
}
// 如果找不到字段,尝试 toString()
String toString = message.toString();
if (toString != null && !toString.startsWith("AssistantMessage@")) {
return toString;
}
return null;
} catch (Exception e) {
log.debug("无法提取 AssistantMessage 文本内容: {}", e.getMessage());
return null;
}
}
/**
* 获取消息角色
*/