e80a82bef4
- 更新 http-error-codes, user-points-chat-data-protocol - 更新 divination-run-protocol, profile-protocol - 删除废弃的后端和前端设计计划文档
201 lines
6.5 KiB
Markdown
201 lines
6.5 KiB
Markdown
# Bug: 追问时 agent_output 被重新生成,导致 sign_level 被覆盖
|
||
|
||
日期:2026-04-08
|
||
状态:已确认根因(未修复)
|
||
|
||
## 问题描述
|
||
|
||
首次解卦完成后,用户继续追问时,agent 会重新生成 `agent_output`,重新计算卦的结论和标签。
|
||
|
||
**具体表现**:
|
||
- 首次解卦结论:`中下签`
|
||
- 追问后结论:`下下签`(同一个卦,结果被重新生成)
|
||
|
||
## 根因分析
|
||
|
||
**问题定位**:`backend/src/core/agentscope/runtime/runner.py`
|
||
|
||
### 根因链条
|
||
|
||
1. **`runner.py:execute()` 方法(第 70-133 行)**
|
||
|
||
无论 `runtime_mode` 是 `chat` 还是 `follow_up`,都会执行以下逻辑:
|
||
|
||
```python
|
||
# 第 105-119 行:始终推导卦象
|
||
derived_divination = self._resolve_derived_divination(run_input=run_input)
|
||
await self._emit_step_event(
|
||
pipeline=pipeline,
|
||
run_input=run_input,
|
||
step_name="divination",
|
||
event_type="DIVINATION_DERIVED", # <-- 追问时不应发射此事件
|
||
...
|
||
)
|
||
```
|
||
|
||
2. **`runner.py:_execute_worker_step()` 方法(第 200-245 行)**
|
||
|
||
始终将 `derived_divination` 传递给 worker:
|
||
|
||
```python
|
||
# 第 294-302 行:始终将 divination_derived 放入 worker_output
|
||
await emitter.emit_final_text_end(
|
||
worker_output={
|
||
**worker_payload.model_dump(mode="json", exclude_none=True),
|
||
"divination_derived": derived_divination.model_dump(...), # <-- 追问时不应包含
|
||
},
|
||
...
|
||
)
|
||
```
|
||
|
||
3. **`stage_emitter.py:emit_final_text_end()` 方法(第 46-73 行)**
|
||
|
||
始终将所有字段放入 `TEXT_MESSAGE_END` 事件:
|
||
|
||
```python
|
||
payload = {
|
||
"messageId": message_id,
|
||
"role": "assistant",
|
||
"stage": self._stage,
|
||
"status": worker_output.get("status"),
|
||
"sign_level": worker_output.get("sign_level"), # <-- 追问时不应有
|
||
"conclusion": worker_output.get("conclusion", []), # <-- 追问时不应有
|
||
"focus_points": worker_output.get("focus_points", []),
|
||
"advice": worker_output.get("advice", []),
|
||
"keywords": worker_output.get("keywords", []),
|
||
"answer": worker_output.get("answer", ""),
|
||
"error": worker_output.get("error"),
|
||
"divination_derived": worker_output.get("divination_derived"), # <-- 追问时不应有
|
||
...
|
||
}
|
||
```
|
||
|
||
4. **`store.py:_persist_text_message()` 方法(第 125-160 行)**
|
||
|
||
从事件中提取所有字段并完整存储:
|
||
|
||
```python
|
||
worker_output_fields = (
|
||
"status",
|
||
"sign_level", # <-- 追问时被重新生成并覆盖
|
||
"conclusion", # <-- 追问时被重新生成并覆盖
|
||
"focus_points",
|
||
"advice",
|
||
"keywords",
|
||
"answer",
|
||
"error",
|
||
"divination_derived", # <-- 追问时被重新生成
|
||
"ui_hints",
|
||
)
|
||
```
|
||
|
||
### 问题本质
|
||
|
||
`runtime_mode=follow_up` 时,系统仍在:
|
||
1. 重新推导卦象(调用 `_resolve_derived_divination`)
|
||
2. 发射完整的 `DIVINATION_DERIVED` 事件
|
||
3. 生成包含所有结构化字段的 `worker_output`
|
||
4. 将所有字段存储到数据库
|
||
|
||
但根据工程计划(`docs/plans/2026-04-08-followup-session-history-eng-plan.md:37-40`),追问时的预期行为是:
|
||
|
||
```
|
||
[一次追问]
|
||
user -> /agent/runs(runtime_mode=follow_up)
|
||
-> assistant(content [+ optional metadata.agent_output.answer])
|
||
```
|
||
|
||
即:追问时只输出 `answer`(内容),不重新生成卦象结构。
|
||
|
||
## 证据
|
||
|
||
### 数据库证据
|
||
|
||
Session `015fe0f9-0500-43ab-911a-4ce8e3160032`:
|
||
|
||
| 时间 | 角色 | sign_level | divination_derived | 问题 |
|
||
|------|------|------------|-------------------|------|
|
||
| 05:21:19 | user | - | - | 首问问题 |
|
||
| 05:21:28 | assistant | 中下签 | 完整 | 首答(正确) |
|
||
| 05:22:24 | user | - | - | 追问 |
|
||
| 05:22:33 | assistant | **下下签** | 完整 | 追问答(sign_level 被重新生成) |
|
||
|
||
两个 assistant 消息的 `divination_derived` 完全相同,但 `sign_level` 不同,证明是重新生成的。
|
||
|
||
## 修复方向
|
||
|
||
### 1. `runner.py`
|
||
|
||
在 `execute()` 方法中,根据 `runtime_mode` 决定是否推导卦象:
|
||
|
||
```python
|
||
async def execute(self, ...):
|
||
runtime_mode = self._resolve_runtime_mode(run_input=run_input)
|
||
|
||
if runtime_mode == RuntimeMode.CHAT:
|
||
derived_divination = self._resolve_derived_divination(run_input=run_input)
|
||
await self._emit_step_event(...) # DIVINATION_DERIVED
|
||
else:
|
||
derived_divination = None # follow_up 不推导
|
||
```
|
||
|
||
### 2. `stage_emitter.py`
|
||
|
||
`emit_final_text_end()` 根据 `runtime_mode` 决定发送哪些字段:
|
||
|
||
```python
|
||
async def emit_final_text_end(self, ..., runtime_mode: str):
|
||
payload = {"messageId": ..., "role": "assistant", "stage": self._stage}
|
||
|
||
if runtime_mode == "chat":
|
||
payload.update({
|
||
"status": worker_output.get("status"),
|
||
"sign_level": worker_output.get("sign_level"),
|
||
"conclusion": worker_output.get("conclusion", []),
|
||
"focus_points": worker_output.get("focus_points", []),
|
||
"advice": worker_output.get("advice", []),
|
||
"keywords": worker_output.get("keywords", []),
|
||
"divination_derived": worker_output.get("divination_derived"),
|
||
...
|
||
})
|
||
else: # follow_up
|
||
payload["answer"] = worker_output.get("answer", "")
|
||
```
|
||
|
||
### 3. `store.py`
|
||
|
||
`_persist_text_message()` 根据 `runtime_mode` 决定提取哪些字段:
|
||
|
||
```python
|
||
async def _persist_text_message(self, ...):
|
||
runtime_mode = self._resolve_runtime_mode(event=event)
|
||
|
||
if runtime_mode == "chat":
|
||
worker_output_fields = ("status", "sign_level", "conclusion", ...)
|
||
else: # follow_up
|
||
worker_output_fields = ("answer",) # 只存储 answer
|
||
```
|
||
|
||
### 4. `runtime_models.py`
|
||
|
||
`resolve_worker_output_model()` 应根据 `runtime_mode` 返回不同 schema:
|
||
|
||
```python
|
||
def resolve_worker_output_model(runtime_mode: RuntimeMode = RuntimeMode.CHAT) -> type[WorkerAgentOutputLite]:
|
||
if runtime_mode == RuntimeMode.FOLLOW_UP:
|
||
return WorkerAgentOutputLite # 只有 answer
|
||
return AgentOutput # 完整结构(继承自 WorkerAgentOutputRich)
|
||
```
|
||
|
||
## 相关文件
|
||
|
||
- `backend/src/core/agentscope/runtime/runner.py` - 主编排逻辑
|
||
- `backend/src/core/agentscope/runtime/stage_emitter.py` - 事件发射
|
||
- `backend/src/core/agentscope/events/store.py` - 事件持久化
|
||
- `backend/src/schemas/agent/runtime_models.py` - 输出 schema 定义
|
||
|
||
## 相关文档
|
||
|
||
- 工程计划:`docs/plans/2026-04-08-followup-session-history-eng-plan.md`
|
||
- 协议文档:`docs/protocols/divination/divination-run-protocol.md`
|