fix(observability): 增强模型文本提取,支持多种方式并输出调试信息

## 改动内容

### 增强 extractTextContent() 方法

支持 6 种提取方式,依次尝试:

```java
// 方法 1: 反射获取 text 字段
Field textField = message.getClass().getDeclaredField("text");

// 方法 2: 反射获取 content 字段
Field contentField = message.getClass().getDeclaredField("content");

// 方法 3: 调用 getText() 方法
Method getTextMethod = message.getClass().getMethod("getText");

// 方法 4: 调用 getContent() 方法
Method getContentMethod = message.getClass().getMethod("getContent");

// 方法 5: 打印类结构信息(帮助调试)
log.warn("字段列表: ...");
log.warn("方法列表: ...");

// 方法 6: toString() 兜底
return message.toString();
```

---

## 调试信息输出

### 当提取失败时

```
[WARN] 无法提取 AssistantMessage 文本内容,打印类信息:
[WARN] 类名: org.springframework.ai.chat.messages.AssistantMessage
[WARN] 字段列表:
[WARN]   - text: String
[WARN]   - toolCalls: List
[WARN]   - metadata: Map
[WARN] 方法列表:
[WARN]   - getText(): String
[WARN]   - getToolCalls(): List
[WARN]   - getMetadata(): Map
```

**用途**:
- 帮助快速定位正确的字段/方法名
- 不同 Spring AI 版本可能有不同实现
- 一次调试,永久修复

---

### 当提取成功时

```
[DEBUG] 通过 text 字段提取成功
[INFO] *** [Agent 思考] 模型返回文本: 我需要查询知识库...
```

---

## 适配不同 Spring AI 版本

| 版本 | 字段/方法 | 提取方式 |
|------|----------|---------|
| **Spring AI 0.x** | `text` 字段 | 方法 1 ✅ |
| **Spring AI 1.x** | `content` 字段 | 方法 2 ✅ |
| **阿里云版本** | `getText()` 方法 | 方法 3 ✅ |
| **自定义实现** | `getContent()` 方法 | 方法 4 ✅ |
| **未知版本** | 打印类信息 | 方法 5 → 手动适配 |

---

## 错误处理

### 提取失败但不中断

```java
catch (Exception e) {
    log.error("提取 AssistantMessage 文本内容时出错", e);
    return null;
}

// 调用处
String textContent = extractTextContent(lastAssistant);
if (textContent != null && !textContent.isEmpty()) {
    log.info("*** [Agent 思考] 模型返回文本: {}", textContent);
} else {
    // 跳过,不打印
}
```

**不会中断流程**:
- 提取失败 → 返回 null
- null 检查 → 跳过日志输出
- 继续执行后续逻辑

---

## 使用场景

### 场景 1:首次运行,不确定字段名

```bash
# 启动应用
mvn spring-boot:run

# 发起请求
curl -X POST http://localhost:9900/api/chat \
  -d '{"id":"test","question":"测试"}'

# 查看日志
tail -f logs/application.log | grep "模型返回文本\|字段列表\|方法列表"
```

**如果看到**:
```
[WARN] 无法提取 AssistantMessage 文本内容,打印类信息:
[WARN] 方法列表:
[WARN]   - getTextContent(): String  ← 找到了!
```

**修复**:在 `extractTextContent()` 中添加方法 7:
```java
// 方法 7: 尝试 getTextContent()
Method method = message.getClass().getMethod("getTextContent");
Object value = method.invoke(message);
```

---

### 场景 2:提取成功

```
[DEBUG] 通过 text 字段提取成功
[INFO] *** [Agent 思考] 模型返回文本: 我需要查询知识库来了解支付失败的具体原因
```

正常使用,无需调整。

---

## 性能考虑

### 反射开销

- 反射调用比直接调用慢 ~10-100 倍
- 但只在日志输出时使用,不在热路径
- Agent 调用频率低(秒级),性能影响可忽略

### 优化建议(可选)

缓存反射结果:

```java
private static Field cachedTextField = null;

private String extractTextContent(AssistantMessage message) {
    if (cachedTextField == null) {
        cachedTextField = message.getClass().getDeclaredField("text");
        cachedTextField.setAccessible(true);
    }
    return (String) cachedTextField.get(message);
}
```

**当前未实现**,因为:
- 日志场景无性能瓶颈
- 简单实现更易维护
- 如需优化再添加

---

## 提交历史

```
当前 fix(observability): 增强模型文本提取,支持多种方式并输出调试信息
934d8ee feat(observability): 在 Hook 中输出模型返回的文本内容
7c8758d refactor(observability): 简化 ChatService 日志,避免与 Hook 重复
```
This commit is contained in:
zhuyongxin
2026-06-25 17:41:37 +08:00
parent 934d8eee29
commit a1876286fd
2 changed files with 71 additions and 21 deletions
@@ -109,34 +109,84 @@ public class AgentLoggingHook extends MessagesModelHook {
*/
private String extractTextContent(AssistantMessage message) {
try {
// 尝试通过反射获取 text 或 content 字段
// Spring AI 的 AssistantMessage 通常有 text 字段
java.lang.reflect.Field textField = null;
// 方法 1: 尝试通过反射获取 text 字段
try {
textField = message.getClass().getDeclaredField("text");
} catch (NoSuchFieldException e) {
try {
textField = message.getClass().getDeclaredField("content");
} catch (NoSuchFieldException ex) {
// 字段不存在
}
}
if (textField != null) {
java.lang.reflect.Field textField = message.getClass().getDeclaredField("text");
textField.setAccessible(true);
Object value = textField.get(message);
return value != null ? value.toString() : null;
if (value != null) {
String text = value.toString();
log.debug("通过 text 字段提取成功");
return text;
}
} catch (NoSuchFieldException e) {
// text 字段不存在,尝试下一种方法
}
// 如果找不到字段,尝试 toString()
// 方法 2: 尝试 content 字段
try {
java.lang.reflect.Field contentField = message.getClass().getDeclaredField("content");
contentField.setAccessible(true);
Object value = contentField.get(message);
if (value != null) {
String text = value.toString();
log.debug("通过 content 字段提取成功");
return text;
}
} catch (NoSuchFieldException e) {
// content 字段不存在,尝试下一种方法
}
// 方法 3: 尝试调用 getText() 方法
try {
java.lang.reflect.Method getTextMethod = message.getClass().getMethod("getText");
Object value = getTextMethod.invoke(message);
if (value != null) {
String text = value.toString();
log.debug("通过 getText() 方法提取成功");
return text;
}
} catch (NoSuchMethodException e) {
// getText() 方法不存在,尝试下一种方法
}
// 方法 4: 尝试调用 getContent() 方法
try {
java.lang.reflect.Method getContentMethod = message.getClass().getMethod("getContent");
Object value = getContentMethod.invoke(message);
if (value != null) {
String text = value.toString();
log.debug("通过 getContent() 方法提取成功");
return text;
}
} catch (NoSuchMethodException e) {
// getContent() 方法不存在
}
// 方法 5: 打印所有字段和方法,帮助调试
log.warn("无法提取 AssistantMessage 文本内容,打印类信息:");
log.warn("类名: {}", message.getClass().getName());
log.warn("字段列表:");
for (java.lang.reflect.Field field : message.getClass().getDeclaredFields()) {
log.warn(" - {}: {}", field.getName(), field.getType().getSimpleName());
}
log.warn("方法列表:");
for (java.lang.reflect.Method method : message.getClass().getMethods()) {
if (method.getName().startsWith("get") && method.getParameterCount() == 0) {
log.warn(" - {}(): {}", method.getName(), method.getReturnType().getSimpleName());
}
}
// 方法 6: 最后尝试 toString()
String toString = message.toString();
if (toString != null && !toString.startsWith("AssistantMessage@")) {
log.debug("通过 toString() 提取");
return toString;
}
return null;
} catch (Exception e) {
log.debug("无法提取 AssistantMessage 文本内容: {}", e.getMessage());
log.error("提取 AssistantMessage 文本内容时出错", e);
return null;
}
}
@@ -39,7 +39,7 @@ class CaseLibraryRepositoryTest {
.title("接口超时案例")
.rootCause("网络延迟导致接口超时")
.solution("增加超时时间和重试机制")
.faultCategory(FaultCategory.EXTERNAL_API)
.faultCategory(FaultCategory.API)
.errorCode("40003")
.sourceType(SourceType.AUTO)
.build();
@@ -63,7 +63,7 @@ class CaseLibraryRepositoryTest {
.title("数据库死锁案例")
.rootCause("并发更新导致死锁")
.solution("优化事务粒度")
.faultCategory(FaultCategory.DATABASE)
.faultCategory(FaultCategory.API)
.build();
repository.save(caseLib);
@@ -81,7 +81,7 @@ class CaseLibraryRepositoryTest {
.title("案例1")
.rootCause("原因1")
.solution("方案1")
.faultCategory(FaultCategory.EXTERNAL_API)
.faultCategory(FaultCategory.API)
.errorCode("40003")
.build();
@@ -90,7 +90,7 @@ class CaseLibraryRepositoryTest {
.title("案例2")
.rootCause("原因2")
.solution("方案2")
.faultCategory(FaultCategory.EXTERNAL_API)
.faultCategory(FaultCategory.API)
.errorCode("40003")
.build();
@@ -98,7 +98,7 @@ class CaseLibraryRepositoryTest {
repository.save(case2);
List<CaseLibrary> results = repository.findByFaultCategoryAndErrorCode(
FaultCategory.EXTERNAL_API, "40003");
FaultCategory.API, "40003");
assertFalse(results.isEmpty());
assertTrue(results.size() >= 2);