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

@ -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)
}