feat(kokoro_flow_chatter): 添加活动流格式配置及上下文构建功能,修复分离模式失效的问题

This commit is contained in:
Windpicker-owo
2025-12-14 23:30:01 +08:00
parent 962a50217d
commit dab7e91fed
7 changed files with 262 additions and 21 deletions

View File

@@ -995,6 +995,27 @@ class KokoroFlowChatterWaitingConfig(ValidatedConfigBase):
)
class KokoroFlowChatterPromptConfig(ValidatedConfigBase):
"""Kokoro Flow Chatter 提示词/上下文构建配置"""
activity_stream_format: Literal["narrative", "table", "both"] = Field(
default="narrative",
description='活动流格式: "narrative"(线性叙事) / "table"(结构化表格) / "both"(两者都输出)',
)
max_activity_entries: int = Field(
default=30,
ge=0,
le=200,
description="活动流最多保留条数越大越完整但token越高",
)
max_entry_length: int = Field(
default=500,
ge=0,
le=5000,
description="活动流单条最大字符数(用于裁剪,避免单条过长拖垮上下文)",
)
class KokoroFlowChatterConfig(ValidatedConfigBase):
"""
Kokoro Flow Chatter 配置类 - 私聊专用心流对话系统
@@ -1031,6 +1052,11 @@ class KokoroFlowChatterConfig(ValidatedConfigBase):
description="自定义KFC决策行为指导提示词unified影响整体split仅影响planner",
)
prompt: KokoroFlowChatterPromptConfig = Field(
default_factory=KokoroFlowChatterPromptConfig,
description="提示词/上下文构建配置(活动流格式、裁剪等)",
)
waiting: KokoroFlowChatterWaitingConfig = Field(
default_factory=KokoroFlowChatterWaitingConfig,
description="等待策略配置(默认等待时间、倍率等)",

View File

@@ -223,6 +223,9 @@ class KokoroFlowChatter(BaseChatter):
exec_results.append(result)
if result.get("success") and action.type in ("kfc_reply", "respond"):
has_reply = True
reply_text = (result.get("reply_text") or "").strip()
if reply_text:
action.params["content"] = reply_text
# 11. 记录 Bot 规划到 mental_log
session.add_bot_planning(
@@ -336,6 +339,12 @@ class KokoroFlowChatter(BaseChatter):
# 为 kfc_reply 动作注入回复生成所需的上下文
for action in plan_response.actions:
if action.type == "kfc_reply":
# 分离模式下 Planner 不应直接生成回复内容;即使模型输出了 content也应忽略
if "content" in action.params and action.params.get("content"):
logger.warning(
"[KFC] Split模式下Planner输出了kfc_reply.content已忽略由Replyer生成"
)
action.params.pop("content", None)
action.params["user_id"] = user_id
action.params["user_name"] = user_name
action.params["thought"] = plan_response.thought

View File

@@ -90,6 +90,12 @@ class PromptConfig:
# 每条记录最大字符数
max_entry_length: int = 500
# 活动流格式narrative线性叙事/ table结构化表格/ both两者都给
# - narrative: 更自然,但信息密度较低,长时更容易丢细节
# - table: 更高信息密度,便于模型对齐字段、检索与对比
# - both: 调试/对照用token 更高
activity_stream_format: str = "narrative"
# 是否包含人物关系信息
include_relation: bool = True
@@ -236,6 +242,11 @@ def load_config() -> KokoroFlowChatterConfig:
config.prompt = PromptConfig(
max_activity_entries=getattr(pmt_cfg, "max_activity_entries", 30),
max_entry_length=getattr(pmt_cfg, "max_entry_length", 500),
activity_stream_format=getattr(
pmt_cfg,
"activity_stream_format",
getattr(pmt_cfg, "activity_format", "narrative"),
),
include_relation=getattr(pmt_cfg, "include_relation", True),
include_memory=getattr(pmt_cfg, "include_memory", True),
)

View File

@@ -456,6 +456,11 @@ class ProactiveThinker:
# 分离模式下需要注入上下文信息
for action in plan_response.actions:
if action.type == "kfc_reply":
if "content" in action.params and action.params.get("content"):
logger.warning(
"[KFC ProactiveThinker] Split模式下Planner输出了kfc_reply.content已忽略由Replyer生成"
)
action.params.pop("content", None)
action.params["user_id"] = session.user_id
action.params["user_name"] = user_name
action.params["thought"] = plan_response.thought
@@ -495,7 +500,7 @@ class ProactiveThinker:
# 执行动作(回复生成在 Action.execute() 中完成)
for action in plan_response.actions:
await action_manager.execute_action(
result = await action_manager.execute_action(
action_name=action.type,
chat_id=session.stream_id,
target_message=None,
@@ -504,6 +509,10 @@ class ProactiveThinker:
thinking_id=None,
log_prefix="[KFC ProactiveThinker]",
)
if result.get("success") and action.type in ("kfc_reply", "respond"):
reply_text = (result.get("reply_text") or "").strip()
if reply_text:
action.params["content"] = reply_text
# 🎯 只有真正发送了消息才增加追问计数do_nothing 不算追问)
has_reply_action = any(
@@ -703,6 +712,11 @@ class ProactiveThinker:
if self._mode == KFCMode.SPLIT:
for action in plan_response.actions:
if action.type == "kfc_reply":
if "content" in action.params and action.params.get("content"):
logger.warning(
"[KFC ProactiveThinker] Split模式下Planner输出了kfc_reply.content已忽略由Replyer生成"
)
action.params.pop("content", None)
action.params["user_id"] = session.user_id
action.params["user_name"] = user_name
action.params["thought"] = plan_response.thought
@@ -735,7 +749,7 @@ class ProactiveThinker:
# 执行动作(回复生成在 Action.execute() 中完成)
for action in plan_response.actions:
await action_manager.execute_action(
result = await action_manager.execute_action(
action_name=action.type,
chat_id=session.stream_id,
target_message=None,
@@ -744,6 +758,10 @@ class ProactiveThinker:
thinking_id=None,
log_prefix="[KFC ProactiveThinker]",
)
if result.get("success") and action.type in ("kfc_reply", "respond"):
reply_text = (result.get("reply_text") or "").strip()
if reply_text:
action.params["content"] = reply_text
# 记录到 mental_log
session.add_bot_planning(

View File

@@ -284,6 +284,42 @@ class PromptBuilder:
return ""
def _build_last_bot_action_block(self, session: KokoroSession | None) -> str:
"""
构建“最近一次Bot动作/发言”块(用于插入到当前情况里)
目的:让模型在决策时能显式参考“我刚刚做过什么/说过什么”,降低长上下文里漏细节的概率。
"""
if not session or not getattr(session, "mental_log", None):
return ""
last_planning_entry: MentalLogEntry | None = None
for entry in reversed(session.mental_log):
if entry.event_type == EventType.BOT_PLANNING:
last_planning_entry = entry
break
if not last_planning_entry:
return ""
actions_desc = self._format_actions(last_planning_entry.actions)
last_message = ""
for action in last_planning_entry.actions:
if action.get("type") == "kfc_reply":
content = (action.get("content") or "").strip()
if content:
last_message = content
if last_message and len(last_message) > 80:
last_message = last_message[:80] + "..."
lines = [f"你最近一次执行的动作是:{actions_desc}"]
if last_message:
lines.append(f"你上一次发出的消息是:「{last_message}")
return "\n".join(lines) + "\n\n"
async def _build_context_data(
self,
user_name: str,
@@ -541,14 +577,39 @@ class PromptBuilder:
构建活动流
将 mental_log 中的事件按时间顺序转换为线性叙事
使用统一的 prompt 模板
支持线性叙事或结构化表格两种格式(可通过配置切换)
"""
entries = session.get_recent_entries(limit=30)
from ..config import get_config
kfc_config = get_config()
prompt_cfg = getattr(kfc_config, "prompt", None)
max_entries = getattr(prompt_cfg, "max_activity_entries", 30) if prompt_cfg else 30
max_entry_length = getattr(prompt_cfg, "max_entry_length", 500) if prompt_cfg else 500
stream_format = (
getattr(prompt_cfg, "activity_stream_format", "narrative") if prompt_cfg else "narrative"
)
entries = session.get_recent_entries(limit=max_entries)
if not entries:
return ""
parts = []
stream_format = (stream_format or "narrative").strip().lower()
if stream_format == "table":
return self._build_activity_stream_table(entries, user_name, max_entry_length)
if stream_format == "both":
table = self._build_activity_stream_table(entries, user_name, max_entry_length)
narrative = await self._build_activity_stream_narrative(entries, user_name)
return "\n\n".join([p for p in (table, narrative) if p])
return await self._build_activity_stream_narrative(entries, user_name)
async def _build_activity_stream_narrative(
self,
entries: list[MentalLogEntry],
user_name: str,
) -> str:
"""构建线性叙事活动流(旧格式)"""
parts: list[str] = []
for entry in entries:
part = await self._format_entry(entry, user_name)
if part:
@@ -556,6 +617,95 @@ class PromptBuilder:
return "\n\n".join(parts)
def _build_activity_stream_table(
self,
entries: list[MentalLogEntry],
user_name: str,
max_cell_length: int = 500,
) -> str:
"""
构建结构化表格活动流(更高信息密度)
统一列:序号 / 时间 / 事件类型 / 内容 / 想法 / 行动 / 结果
"""
def truncate(text: str, limit: int) -> str:
if not text:
return ""
if limit <= 0:
return text
text = text.strip()
return text if len(text) <= limit else (text[: max(0, limit - 1)] + "")
def md_cell(value: str) -> str:
value = (value or "").replace("\r\n", "\n").replace("\n", "<br>")
value = value.replace("|", "\\|")
return truncate(value, max_cell_length)
event_type_alias = {
EventType.USER_MESSAGE: "用户消息",
EventType.BOT_PLANNING: "你的决策",
EventType.WAITING_UPDATE: "等待中",
EventType.PROACTIVE_TRIGGER: "主动触发",
}
header = ["#", "时间", "类型", "内容", "想法", "行动", "结果"]
lines = [
"|" + "|".join(header) + "|",
"|" + "|".join(["---"] * len(header)) + "|",
]
for idx, entry in enumerate(entries, 1):
time_str = entry.get_time_str()
type_str = event_type_alias.get(entry.event_type, str(entry.event_type))
content = ""
thought = ""
action = ""
result = ""
if entry.event_type == EventType.USER_MESSAGE:
content = entry.content
reply_status = entry.metadata.get("reply_status")
if reply_status in ("in_time", "late"):
elapsed_min = entry.metadata.get("elapsed_seconds", 0) / 60
max_wait_min = entry.metadata.get("max_wait_seconds", 0) / 60
status_cn = "及时" if reply_status == "in_time" else "迟到"
result = f"回复{status_cn}(等{elapsed_min:.1f}/{max_wait_min:.1f}分钟)"
elif entry.event_type == EventType.BOT_PLANNING:
thought = entry.thought or "(无)"
action = self._format_actions(entry.actions)
if entry.max_wait_seconds > 0:
wait_min = entry.max_wait_seconds / 60
expected = entry.expected_reaction or "(无)"
result = f"等待≤{wait_min:.1f}分钟;期待={expected}"
else:
result = "不等待"
elif entry.event_type == EventType.WAITING_UPDATE:
thought = entry.waiting_thought or "还在等…"
elapsed_min = entry.elapsed_seconds / 60
mood = (entry.mood or "").strip()
result = f"已等{elapsed_min:.1f}分钟" + (f";心情={mood}" if mood else "")
elif entry.event_type == EventType.PROACTIVE_TRIGGER:
silence = entry.metadata.get("silence_duration", "一段时间")
result = f"沉默{silence}"
row = [
str(idx),
md_cell(time_str),
md_cell(type_str),
md_cell(content),
md_cell(thought),
md_cell(action),
md_cell(result),
]
lines.append("|" + "|".join(row) + "|")
return "(结构化活动流表;按时间顺序)\n" + "\n".join(lines)
async def _format_entry(self, entry: MentalLogEntry, user_name: str) -> str:
"""格式化单个活动日志条目"""
@@ -661,6 +811,7 @@ class PromptBuilder:
) -> str:
"""构建当前情况描述"""
current_time = datetime.now().strftime("%Y年%m月%d%H:%M")
last_action_block = self._build_last_bot_action_block(session)
# 如果之前没有设置等待时间max_wait_seconds == 0视为 new_message
if situation_type in ("reply_in_time", "reply_late"):
@@ -674,6 +825,7 @@ class PromptBuilder:
return await global_prompt_manager.format_prompt(
PROMPT_NAMES["situation_new_message"],
current_time=current_time,
last_action_block=last_action_block,
user_name=user_name,
latest_message=latest_message,
)
@@ -685,6 +837,7 @@ class PromptBuilder:
return await global_prompt_manager.format_prompt(
PROMPT_NAMES["situation_reply_in_time"],
current_time=current_time,
last_action_block=last_action_block,
user_name=user_name,
elapsed_minutes=elapsed / 60,
max_wait_minutes=max_wait / 60,
@@ -698,6 +851,7 @@ class PromptBuilder:
return await global_prompt_manager.format_prompt(
PROMPT_NAMES["situation_reply_late"],
current_time=current_time,
last_action_block=last_action_block,
user_name=user_name,
elapsed_minutes=elapsed / 60,
max_wait_minutes=max_wait / 60,
@@ -743,6 +897,7 @@ class PromptBuilder:
return await global_prompt_manager.format_prompt(
PROMPT_NAMES["situation_timeout"],
current_time=current_time,
last_action_block=last_action_block,
user_name=user_name,
elapsed_minutes=elapsed / 60,
max_wait_minutes=max_wait / 60,
@@ -756,6 +911,7 @@ class PromptBuilder:
return await global_prompt_manager.format_prompt(
PROMPT_NAMES["situation_proactive"],
current_time=current_time,
last_action_block=last_action_block,
user_name=user_name,
silence_duration=silence,
trigger_reason=trigger_reason,
@@ -766,6 +922,7 @@ class PromptBuilder:
PROMPT_NAMES["situation_new_message"],
current_time=current_time,
user_name=user_name,
last_action_block=last_action_block,
)
def _build_actions_block(self, available_actions: dict | None) -> str:
@@ -926,15 +1083,17 @@ class PromptBuilder:
"""
from datetime import datetime
current_time = datetime.now().strftime("%Y年%m月%d%H:%M")
last_action_block = self._build_last_bot_action_block(session)
if situation_type == "new_message":
return f"现在是 {current_time}{user_name} 刚给你发了消息。"
return f"现在是 {current_time}\n\n{last_action_block}{user_name} 刚给你发了消息。"
elif situation_type == "reply_in_time":
elapsed = session.waiting_config.get_elapsed_seconds()
max_wait = session.waiting_config.max_wait_seconds
return (
f"现在是 {current_time}\n"
f"现在是 {current_time}\n\n"
f"{last_action_block}"
f"你之前发了消息后在等 {user_name} 的回复。"
f"等了大约 {elapsed / 60:.1f} 分钟(你原本打算最多等 {max_wait / 60:.1f} 分钟)。"
f"现在 {user_name} 回复了!"
@@ -944,7 +1103,8 @@ class PromptBuilder:
elapsed = session.waiting_config.get_elapsed_seconds()
max_wait = session.waiting_config.max_wait_seconds
return (
f"现在是 {current_time}\n"
f"现在是 {current_time}\n\n"
f"{last_action_block}"
f"你之前发了消息后在等 {user_name} 的回复。"
f"你原本打算最多等 {max_wait / 60:.1f} 分钟,但实际等了 {elapsed / 60:.1f} 分钟才收到回复。"
f"虽然有点迟,但 {user_name} 终于回复了。"
@@ -954,7 +1114,8 @@ class PromptBuilder:
elapsed = session.waiting_config.get_elapsed_seconds()
max_wait = session.waiting_config.max_wait_seconds
return (
f"现在是 {current_time}\n"
f"现在是 {current_time}\n\n"
f"{last_action_block}"
f"你之前发了消息后一直在等 {user_name} 的回复。"
f"你原本打算最多等 {max_wait / 60:.1f} 分钟,现在已经等了 {elapsed / 60:.1f} 分钟了,对方还是没回。"
f"你决定主动说点什么。"
@@ -963,13 +1124,14 @@ class PromptBuilder:
elif situation_type == "proactive":
silence = extra_context.get("silence_duration", "一段时间")
return (
f"现在是 {current_time}\n"
f"现在是 {current_time}\n\n"
f"{last_action_block}"
f"你和 {user_name} 已经有一段时间没聊天了(沉默了 {silence})。"
f"你决定主动找 {user_name} 聊点什么。"
)
# 默认
return f"现在是 {current_time}"
return f"现在是 {current_time}\n\n{last_action_block}".rstrip()
async def _build_reply_context(
self,

View File

@@ -34,7 +34,7 @@ kfc_MAIN_PROMPT = Prompt(
{tool_info}
# 你们之间最近的活动记录
以下是你和 {user_name} 最近的互动历史,按时间顺序记录了你们的对话和你的心理活动:
以下是你和 {user_name} 最近的互动历史,按时间顺序记录了你们的对话和你的心理活动(可能是线性叙事或结构化表格)
{activity_stream}
# 聊天历史总览
@@ -69,7 +69,7 @@ kfc_OUTPUT_FORMAT = Prompt(
{{"type": "动作名称", ...动作参数}}
],
"expected_reaction": "你期待对方的反应是什么",
- `max_wait_seconds`预估的等待时间请根据对话节奏来判断。通常你应该设置为0避免总是等待显得聒噪但是当你觉得你需要等待对方回复时可以设置一个合理的等待时间。
"max_wait_seconds": 0
}}
```
@@ -93,7 +93,7 @@ kfc_SITUATION_NEW_MESSAGE = Prompt(
name="kfc_situation_new_message",
template="""现在是 {current_time}
{user_name} 刚刚给你发了消息:「{latest_message}
{last_action_block}{user_name} 刚刚给你发了消息:「{latest_message}
这是一次新的对话发起(不是对你之前消息的回复)。
@@ -108,7 +108,7 @@ kfc_SITUATION_REPLY_IN_TIME = Prompt(
name="kfc_situation_reply_in_time",
template="""现在是 {current_time}
你之前发了消息后一直在等 {user_name} 的回复。
{last_action_block}你之前发了消息后一直在等 {user_name} 的回复。
等了大约 {elapsed_minutes:.1f} 分钟(你原本打算最多等 {max_wait_minutes:.1f} 分钟)。
现在 {user_name} 回复了:「{latest_message}
@@ -119,7 +119,7 @@ kfc_SITUATION_REPLY_LATE = Prompt(
name="kfc_situation_reply_late",
template="""现在是 {current_time}
你之前发了消息后在等 {user_name} 的回复。
{last_action_block}你之前发了消息后在等 {user_name} 的回复。
你原本打算最多等 {max_wait_minutes:.1f} 分钟,但实际等了 {elapsed_minutes:.1f} 分钟才收到回复。
虽然有点迟,但 {user_name} 终于回复了:「{latest_message}
@@ -130,7 +130,7 @@ kfc_SITUATION_TIMEOUT = Prompt(
name="kfc_situation_timeout",
template="""现在是 {current_time}
你之前发了消息后一直在等 {user_name} 的回复。
{last_action_block}你之前发了消息后一直在等 {user_name} 的回复。
你原本打算最多等 {max_wait_minutes:.1f} 分钟,现在已经等了 {elapsed_minutes:.1f} 分钟了,对方还是没回。
你当时期待的反应是:"{expected_reaction}"
{timeout_context}
@@ -161,7 +161,7 @@ kfc_SITUATION_PROACTIVE = Prompt(
name="kfc_situation_proactive",
template="""现在是 {current_time}
你和 {user_name} 已经有一段时间没聊天了(沉默了 {silence_duration})。
{last_action_block}你和 {user_name} 已经有一段时间没聊天了(沉默了 {silence_duration})。
{trigger_reason}
你在想要不要主动找 {user_name} 聊点什么。
@@ -251,7 +251,7 @@ kfc_PLANNER_OUTPUT_FORMAT = Prompt(
{{"type": "动作名称", ...动作参数}}
],
"expected_reaction": "你期待对方的反应是什么",
- `max_wait_seconds`预估的等待时间请根据对话节奏来判断。通常你应该设置为0避免总是等待显得聒噪但是当你觉得你需要等待对方回复时可以设置一个合理的等待时间。
"max_wait_seconds": 0
}}
```
@@ -264,6 +264,7 @@ kfc_PLANNER_OUTPUT_FORMAT = Prompt(
### 注意事项
- 动作参数直接写在动作对象里,不需要 `action_data` 包装
- **分离模式规则**Planner 阶段禁止输出 `kfc_reply.content`(就算写了也会被系统忽略,回复内容由 Replyer 单独生成)
- 即使什么都不想做,也放一个 `{{"type": "do_nothing"}}`
- 可以组合多个动作,比如先发消息再发表情""",
)
@@ -406,7 +407,7 @@ kfc_UNIFIED_OUTPUT_FORMAT = Prompt(
{{"type": "kfc_reply", "content": "你的回复内容"}}
],
"expected_reaction": "你期待对方的反应是什么",
- `max_wait_seconds`预估的等待时间请根据对话节奏来判断。通常你应该设置为0避免总是等待显得聒噪但是当你觉得你需要等待对方回复时可以设置一个合理的等待时间。
"max_wait_seconds": 0
}}
```

View File

@@ -1,5 +1,5 @@
[inner]
version = "8.0.0"
version = "8.0.1"
#----以下是给开发人员阅读的如果你只是部署了MoFox-Bot不需要阅读----
#如果你想要修改配置文件请递增version的值
@@ -638,6 +638,20 @@ enable_continuous_thinking = true # 是否在等待期间启用心理活动更
# 留空则不生效
custom_decision_prompt = ""
# --- 提示词/上下文构建配置 ---
[kokoro_flow_chatter.prompt]
# 活动流格式(你们之间最近发生的事)
# - "narrative": 线性叙事(更自然,但信息密度较低,长时更容易丢细节)
# - "table": 结构化表格(更高信息密度、更利于模型对齐字段;推荐)
# - "both": 同时输出表格 + 叙事(对照/调试用token 更高)
activity_stream_format = "table"
# 活动流最多保留条数(越大越完整,但 token 越高)
max_activity_entries = 5
# 表格单元格/叙事单条的最大字符数(用于裁剪,避免某条过长拖垮上下文)
max_entry_length = 500
# --- 等待策略 ---
[kokoro_flow_chatter.waiting]
default_max_wait_seconds = 300 # LLM 未给出等待时间时的默认值