fix(prompt): 拼装时空行翻倍 + 人格支持版本占位符 + 配置项说明对齐分层原则

## 1. 提示词里的空行翻倍(用户实测发现)

抓包看实际 payload,system 里出现 3 处连续 3~4 个换行:

    …别包装成"稍等我看一下"。\n\n\n【人格设定】
    …/mascot.webp 直接能看。\n\n\n\n【场景记忆】
    …能否同轮发出。\n\n\n【输出规则】

根因:拼装统一用 `prompt += "\n\n" + block`,而块自己常以换行结尾
(人格块是 "【人格设定】\n%s\n",自定义 system_prompt 也常带尾部换行)。
空行不致命但推高 token,也让「提示词到底长什么样」难以肉眼核对。

修法:新增 appendPromptBlock(trim 块两端 + 只拼一个 "\n\n"),所有拼装点改用它。
重新抓包:连续 3+ 换行 0 处。

## 2. 人格块支持版本占位符

buildSystemPrompt 只对 systemPrompt 调 expandPromptVars,人格块没展开 ——
人格里写死版本号仍会随发版说谎(实测线上人格卡写 v1.0.3)。现在人格同样展开
{{kernel_version}}/{{kernel_commit}}/{{sdk_version}}。

## 3. 人格/系统提示词两个配置项的说明重写

- core.agent.personal_prompt:DisplayName 改为「人格设定(称呼/语气/说话方式/形象)」,
  说明只写人设;工具用法写工具描述、多步流程写技能;支持占位符;文件优先。
- core.agent.system_prompt:DisplayName 改为「系统提示词(身份与行为规则)」,
  说明只写跨工具/跨通道成立的内容;运行时通道投递/并行/时序语义由内核注入无需重复;
  留在提示词里的工具用法会随插件外置而失真。

## 4. 【记忆清理指令】→【记忆维护】

具体选哪个 memory_* 工具、各自副作用早已写在工具描述里(【记忆清理】前缀),
提示词里那份逐条列举属于重复。只保留「必须真做、不能只描述计划」这条
不看工具描述也会犯的错,并要求用一句人话说结果。

## 5. 新增 opt-in 提示词抓包(排查用)

HOMED_PROMPT_DUMP=<path> 时把每轮实际发给 LLM 的 messages 落 jsonl(0600)。
环境变量而非常规配置项:内容是用户隐私,只该在诊断时临时开。
本次排查即由它拿到证据;诊断后已移除临时 drop-in。

验证:go test(agent core / config / sdk / healthcheck)全绿;
实例重启后抓包复核空行归零;对话口吻仍为「短、口语、不列清单」。
This commit is contained in:
root
2026-10-20 09:16:07 +08:00
parent d35b7b7275
commit b9097986c8
6 changed files with 132 additions and 20 deletions

View File

@ -103,7 +103,9 @@ func TestPromptKeepsExistingContract(t *testing.T) {
"【输出规则】",
"output_list_channels",
"【可用工具能力】",
"【记忆清理指令】",
// 记忆维护块:标题从【记忆清理指令】改为【记忆维护】——具体工具选择
// 下沉到 memory_* 的工具描述,提示词只保留「必须真做、不许只描述计划」。
"【记忆维护】",
} {
if !strings.Contains(p, want) {
t.Errorf("提示词丢失既有要点 %q", want)

View File

@ -0,0 +1,68 @@
package core
import (
"encoding/json"
"os"
"sync"
"time"
agentAPI "github.com/JianFeeeee/HomeAgent/internal/agent/api"
)
// 提示词抓包(opt-in 诊断)。
//
// 为什么需要:系统提示词是**每轮动态拼装**的(身份 + 人格 + 记忆 + 时序 +
// 工具顺序 + 输出规则 + 技能索引 + 首启门禁…),只看代码永远说不清模型
// 这一轮到底收到了什么。排查「它怎么又列清单了」「为什么它说有某能力」
// 这类问题时,唯一可靠的事实是**实际发出去的 payload**。
//
// 开关是环境变量而不是配置项:它会把记忆与用户输入原样落盘,属于
// 临时诊断手段,不该在界面上有一个能被随手打开的开关。
//
// HOMED_PROMPT_DUMP=/var/log/homeagent/prompt.jsonl
//
// 每行一条 JSON:时间、通道、模型、完整 messages(role + content)。
// 文件权限 0600 —— 内容是用户隐私。
const promptDumpEnv = "HOMED_PROMPT_DUMP"
var promptDumpMu sync.Mutex
func dumpPrompt(channel string, req *agentAPI.CompletionRequest) {
path := os.Getenv(promptDumpEnv)
if path == "" || req == nil {
return
}
entry := map[string]interface{}{
"at": time.Now().Format(time.RFC3339Nano),
"channel": channel,
"model": req.Model,
}
msgs := make([]map[string]interface{}, 0, len(req.Messages))
for _, m := range req.Messages {
item := map[string]interface{}{"role": m.Role, "content": m.Content}
if len(m.Blocks) > 0 {
item["blocks"] = len(m.Blocks)
}
if len(m.ToolCalls) > 0 {
item["tool_calls"] = len(m.ToolCalls)
}
msgs = append(msgs, item)
}
entry["messages"] = msgs
entry["tool_count"] = len(req.Tools)
line, err := json.Marshal(entry)
if err != nil {
return
}
line = append(line, '\n')
promptDumpMu.Lock()
defer promptDumpMu.Unlock()
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
if err != nil {
return
}
defer f.Close()
f.Write(line)
}

View File

@ -1297,6 +1297,9 @@ func (a *Agent) callLLMWithFallback(req *agentAPI.CompletionRequest, providers [
}
}
// opt-in 提示词抓包(HOMED_PROMPT_DUMP):见 promptdump.go。
dumpPrompt(channel, req)
fCtx, fCancel := context.WithCancel(a.ctx)
a.llmMu.Lock()
a.cancelLLM = fCancel

View File

@ -131,6 +131,29 @@ func expandPromptVars(s string) string {
).Replace(s)
}
// appendPromptBlock 把一个「整块」提示词接到已拼好的提示词后面。
//
// 为什么不能直接 `prompt += "\n\n" + block`:块自己常常以换行结尾
// (人格块是 "【人格设定】\n%s\n"、自定义 system_prompt 一般也有尾部换行),
// 直接拼就会得到 3~4 个连续换行 —— 实测一轮请求里出现 3 处:
//
// …别包装成"稍等我看一下"。\n\n\n【人格设定】 ← 多一个空行
// …/mascot.webp 直接能看。\n\n\n\n【场景记忆】 ← 多两个空行
// …能否同轮发出。\n\n\n【输出规则】 ← 多一个空行
//
// 空行本身不致命,但会推高 token、也让「提示词到底长什么样」难以肉眼核对。
// 这里统一 trim 掉块两端空白再拼两个换行。
func appendPromptBlock(prompt, block string) string {
block = strings.TrimSpace(block)
if block == "" {
return prompt
}
if prompt == "" {
return block
}
return strings.TrimRight(prompt, "\n") + "\n\n" + block
}
func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
prompt := expandPromptVars(a.systemPrompt)
if prompt == "" {
@ -139,20 +162,25 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
// 驻留子:在**固定提示词之上**注入任务提示词(设计 §7「创建」)。
if a.taskPrompt != "" {
prompt += "\n\n【任务】" + a.taskPrompt
prompt = appendPromptBlock(prompt, "【任务】"+a.taskPrompt)
}
if a.personality != nil {
if pp := a.personality.InjectPrompt(); pp != "" {
prompt += "\n\n" + pp
// 人格卡同样展开版本占位符:人格里写死版本号会随发版说谎
// (实测线上人格卡写 v1.0.3,内核早已 1.3.x)。
prompt = appendPromptBlock(prompt, expandPromptVars(pp))
}
}
if memContext != "" {
prompt += "\n\n" + memContext
prompt = appendPromptBlock(prompt, memContext)
}
prompt += "\n\n【记忆清理指令】当用户要求整理或清理记忆时,你必须实际调用 memory_ 工具执行操作,不能只回复文本。先用 memory_introspect 查看概况,再用 memory_recall 获取详情。有同义实体则用 memory_merge 合并(source 会被彻底删除),有无用噪音实体则用 memory_delete_entity 直接删除,也可用 memory_purge 批量清理,用 memory_edit 修正错误,用 memory_block_merge 标记不合并。如果工具执行成功,把结果告知用户;不要只描述计划而不执行。"
// 记忆维护:具体选哪个工具、各有什么副作用,都写在 memory_* 的工具描述里
//(【记忆清理】前缀)。这里只保留那条**不看描述也会犯**的错:只描述计划不执行。
prompt = appendPromptBlock(prompt, "【记忆维护】用户要求整理或清理记忆时,必须真的调用 memory_* 工具去做,"+
"不能只描述计划;选哪个工具、各有什么副作用见对应工具描述。做完用一句人话说结果。")
// 跨模态召回:文本路(fastText/TF-IDF 文档层,媒体描述文本已随记忆进入)
// + 视觉路(多模态文本编码 → 媒体库坐标)两路归一化融合。
@ -160,11 +188,11 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
if a.docStore != nil {
hits := a.retrieveCrossModal(userInput, 3, a.fusionCfg)
if md := a.crossModalMarkdown(hits); md != "" {
prompt += "\n\n" + md
prompt = appendPromptBlock(prompt, md)
}
}
prompt += "\n\n【中断消息】长任务执行期间,工具/插件/定时器等会通过中断机制向你发送提醒(如 QQ 新消息、终端输出到达、定时器到点等)。中断消息以 system 角色注入,内容带 [中断消息] 前缀,**不是用户发言,但也必须认真处理**:优先停下当前长任务,针对中断内容作出响应或决定继续执行。不要忽略带 [中断消息] 前缀的 system 消息。"
prompt = appendPromptBlock(prompt, "【中断消息】长任务执行期间,工具/插件/定时器等会通过中断机制向你发送提醒(如 QQ 新消息、终端输出到达、定时器到点等)。中断消息以 system 角色注入,内容带 [中断消息] 前缀,**不是用户发言,但也必须认真处理**:优先停下当前长任务,针对中断内容作出响应或决定继续执行。不要忽略带 [中断消息] 前缀的 system 消息。")
// 工具执行顺序(阶段 2.5)。必须在**并行执行落地之后**才加:
// 反序会让本段对模型说谎——说"并发"而内核仍串行,模型据此推断
@ -172,13 +200,13 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
//
// ⚠️ 措辞必须与 batchRunnable 的**真实**判据一致(全批 ParallelSafe
// 才并发 + 同通道保序),否则只是把"没说"换成"说错"。
prompt += "\n\n【工具执行顺序】同一条回复里给出多个工具调用时,内核**默认并行执行**(同时跑),不是依次执行。这带来三条你必须知道的规则:\n"
prompt = appendPromptBlock(prompt, "【工具执行顺序】同一条回复里给出多个工具调用时,内核**默认并行执行**(同时跑),不是依次执行。这带来三条你必须知道的规则:\n")
prompt += "- **不要依赖执行顺序**:若某个调用的参数需要另一个调用的结果,就**分两轮**——先发前一个,看到结果后再发下一个。写在同一轮里就等于假设了不存在的先后。\n"
prompt += "- **例外一:同一通道的输出发送会保序**。对**同一个**通道连续调用多次 `output_send__`,内核会**按你发出的顺序依次执行**(用户可见消息顺序敏感),不会并发打乱。所以「先发回执、再发结论」这类有序发送可以放在同一轮;但若后续内容依赖前一条的用户反应,仍应分轮。\n"
prompt += "- **例外二:不并发安全的工具不会并发**。写类工具(记忆/知识/文档写入等)与未声明并发安全的工具,**整批会退回依次执行**——同一批里只要有一个这样的工具,这一批就全部串行。这对你是透明的:你不需要判断,只需知道「同轮并发」不是无条件保证的。\n"
prompt += "- 工具是否并发安全由**工具自己声明**(`ParallelSafe`),不由你决定;你只需按上面第 1 条判断能否同轮发出。\n"
prompt += "\n\n【输出规则】消息不会自动发送到对话来源通道,你必须自己决定如何回复:\n"
prompt = appendPromptBlock(prompt, "【输出规则】消息不会自动发送到对话来源通道,你必须自己决定如何回复:\n")
prompt += "- **不要假设当前通道是某个固定值**:同一会话里可能同时有多个来源(多设备、多通道、子任务)。\n"
prompt += " 先看这条消息本身与上下文里的来源信息,再决定往哪里回;不确定有哪些通道时先调 output_list_channels。\n"
prompt += "- 同步通道(webui / cli / 终端):直接返回纯文本,内核会把文本交给等待方显示,无需调用工具。\n"
@ -203,7 +231,7 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
// 主动 skill_info 拉取全文按文档执行
if a.skillIndex != nil {
if idx := a.skillIndex.SkillIndex(); idx != "" {
prompt += "\n\n【可用技能】以下是已安装的原生技能。当用户请求与某技能描述匹配时,\n先用 skill_info(\"技能名\") 拉取全文,再严格按文档步骤执行:\n" + idx
prompt = appendPromptBlock(prompt, "【可用技能】以下是已安装的原生技能。当用户请求与某技能描述匹配时,\n先用 skill_info(\"技能名\") 拉取全文,再严格按文档步骤执行:\n"+idx)
}
}
@ -211,10 +239,10 @@ func (a *Agent) buildSystemPrompt(memContext string, userInput string) string {
// 系统提示词每轮重建,因此 WebUI / QQ / CLI / ACP / 邮件等所有通道都会带上它;
// 模型调用 persona_set(或用户在 WebUI 向导里选)落地后,标记置位,本段消失。
if a.personaStore != nil && !a.personaStore.PersonaInitialized() {
prompt += "\n\n【首启人格设定】你的**人格设定尚未确认**。请在本轮回复里先问用户一句:" +
"要用默认人格,还是自定义一个?拿到明确答复后**必须调用 persona_set 工具**落库:" +
"用户选默认 → mode=default;自定义 → mode=custom 且把内容写进 content;" +
"用户说以后再说 → mode=later。用户答复前不要假设已设置,也不要反复追问同一件事。"
prompt = appendPromptBlock(prompt, "【首启人格设定】你的**人格设定尚未确认**。请在本轮回复里先问用户一句:"+
"要用默认人格,还是自定义一个?拿到明确答复后**必须调用 persona_set 工具**落库:"+
"用户选默认 → mode=default;自定义 → mode=custom 且把内容写进 content;"+
"用户说以后再说 → mode=later。用户答复前不要假设已设置,也不要反复追问同一件事。")
}
prompt += a.buildToolCatalog()

View File

@ -843,10 +843,13 @@ func (r *ConfigRegistry) seedCoreDefs(dataDir string, fresh bool) {
// 人格设定(人格卡的配置项化):默认模板见 DefaultPersonaPrompt。
// 高级用户仍可用 <dataDir>/personal/personal.md 覆盖它。
reg(ConfigDef{Key: "core.agent.personal_prompt", Default: DefaultPersonaPrompt, Type: "text", DisplayName: "人格设定",
Description: "人格设定块(作为【人格设定】拼在系统提示词之前)。留空则该块不注入。" +
"默认模板不含版本号:被问到版本/构建信息时,应读运行时快照而非凭记忆断言。" +
"<dataDir>/personal/personal.md 存在且非空时优先于本项。", Category: "agent"})
reg(ConfigDef{Key: "core.agent.personal_prompt", Default: DefaultPersonaPrompt, Type: "text", DisplayName: "人格设定(称呼/语气/说话方式/形象)",
Description: "人格块正文,作为【人格设定】拼进系统提示词。**只写人设**:怎么称呼用户、语气与说话方式、" +
"语言习惯、形象设定。工具用法写工具自己的描述,多步流程写技能(skill),不要写在这里 —— " +
"写在人格里会随插件增删而失真(工具被卸载/未启动时提示词还在教模型用它)。" +
"支持版本占位符 {{kernel_version}} / {{kernel_commit}} / {{sdk_version}}(随构建实时展开,避免写死版本号)。" +
"<dataDir>/personal/personal.md 存在且非空时**优先于本项**(该文件不支持占位符展开,建议逐步迁到本项)。" +
"留空则该块不注入。修改后需重启生效。", Category: "agent"})
reg(ConfigDef{Key: "webui.listen_addr", Default: ":8080", Type: "string", DisplayName: "监听地址", Description: "WebUI HTTP 监听地址", Category: "webui"})
reg(ConfigDef{Key: "core.daemon.data_dir", Default: dataDir, Type: "string", DisplayName: "数据目录", Description: "数据存储根目录", Category: "daemon"})
@ -926,7 +929,14 @@ func (r *ConfigRegistry) seedCoreDefs(dataDir string, fresh bool) {
reg(ConfigDef{Key: "core.agent.workdir", Default: "", Type: "string", DisplayName: "工作目录", Description: "Agent 命令执行的默认工作目录(如 cmd_run 工具的 fallback),留空使用内核所在目录", Category: "agent"})
reg(ConfigDef{Key: "core.agent.embedding_model_path", Default: "", Type: "string", DisplayName: "预训练词嵌入模型路径", Description: "预训练词嵌入模型路径(word2vec 文本格式),支持逗号分隔多个模型。路径后可加 #topN 规格只加载前 N 个词向量(如 /data/cc.zh.300.vec#top50000)以控制常驻内存,词频降序命中覆盖绝大部分文本。空则使用 TF-IDF 回退。修改后需重启生效。", Category: "agent"})
reg(ConfigDef{Key: "core.agent.onnx_model_path", Default: "", Type: "string", DisplayName: "ONNX 模型路径", Description: "依存句法分析 ONNX 模型文件路径。留空使用二进制内嵌模型/规则引擎。修改后需重启生效。", Category: "agent"})
regMeta(ConfigDef{Key: "core.agent.system_prompt", Default: "", Type: "text", DisplayName: "系统身份提示词", Description: "Agent 的系统提示词,定义身份和行为规则。留空则使用编译时内置默认值。支持版本占位符(随构建实时展开,避免写死版本号随发版说谎):{{kernel_version}}、{{kernel_commit}}、{{sdk_version}}。修改后需重启生效。", Category: "agent"})
regMeta(ConfigDef{Key: "core.agent.system_prompt", Default: "", Type: "text", DisplayName: "系统提示词(身份与行为规则)",
Description: "系统提示词正文,定义身份与行为规则(不是工具说明书)。**只写跨工具/跨通道都成立的内容**:" +
"身份定位、正确性约束(不编造、只依据工具返回值)、回复的基本要求。" +
"单工具「何时用/参数/坑」写该工具自己的 description;多步流程写技能(skill_info 按需取);" +
"称呼/语气/说话方式写「人格设定」。写在系统提示词里的工具用法会随插件外置/按需加载而失真。" +
"运行时的通道投递规则、同轮并行语义、对话时序语义由内核每轮自动注入,无需在此重复。" +
"留空则使用编译时内置默认值。支持版本占位符(随构建实时展开,避免写死版本号):" +
"{{kernel_version}}、{{kernel_commit}}、{{sdk_version}}。修改后需重启生效。", Category: "agent"})
reg(ConfigDef{Key: "core.input_processing.image.fallback_provider", Default: "", Type: "string", DisplayName: "图片回退提供商", Description: "当主 LLM 不支持图片处理时使用的提供商(留空则自动降级为文字描述)", Category: "input"})
reg(ConfigDef{Key: "core.input_processing.image.fallback_model", Default: "", Type: "string", DisplayName: "图片回退模型", Description: "图片回退提供商使用的模型名", Category: "input"})

View File

@ -68,7 +68,8 @@ const defaultSystemPrompt = `你是 HomeAgent,一个持续运行的个人管
只根据工具真实返回的内容作答:没取到就如实说没取到,不要补全、猜测或编造。
回复用自然语言与用户交流。`
回复用自然语言,像跟人聊天:不要罗列调用了哪个工具、参数是什么、耗时多少,
除非用户明确要求。称呼、语气与说话方式见【人格设定】。`
func buildAgent(k *kernelboot.KernelState) *agentCore.Agent {
cfg := k.Cfg