先说结论
生成一篇文章只需要一次 LLM 调用,但改好一篇文章往往需要多轮对话。
很多人做内容生成 Agent,生成完就结束了 --- 用户不满意只能"重新生成",结果每次都是一篇全新的文章,之前的风格、结构、措辞全丢了。这不是协作,是抽奖。
在 self-media-agent 项目里,chat/ 模块实现了完整的对话式修改链路:
用户提建议 → LLM 按建议编辑 → 保存修改记录 → 更新内容 → 下次生成自动进化
三个模型,一条链路,让 Agent 从"一次性生成器"变成"可协作的编辑助手"。
| 模型 | 做什么 | 存什么 |
|---|---|---|
ChatSession |
绑定到某篇内容的对话会话 | 消息列表 + 修改记录列表 |
ChatMessage |
一条对话消息 | 角色 + 内容 + 时间 |
RevisionRecord |
一次修改的完整记录 | 原文 + 修改后文 + 建议 |
Chat as Interface --- 对话不是聊天,是最高效的人机协作方式。
一、为什么"对话修改"比"重新生成"好?
重新生成的问题
erlang
用户:这篇文章太正式了
Agent:(重新生成)→ 全新文章,但风格可能又变了,之前喜欢的部分也没了
用户:不是让你全改,是让你改语气,结构别动
Agent:(重新生成)→ 又是全新文章...
用户:算了,我自己改吧
重新生成的三个致命问题:
| 问题 | 说明 | 后果 |
|---|---|---|
| 风格漂移 | 每次生成都是"从零开始" | 用户刚满意的风格,下次又变了 |
| 上下文丢失 | 不记得上一版改了什么 | 用户重复提同样的要求 |
| 全有或全无 | 要么全接受要么全重来 | 无法局部修改 |
对话修改的优势
用户:这篇文章太正式了,活泼点
Agent:(按建议编辑,保留原文结构和措辞,只调整语气)
用户:很好!但第三段太长了,缩短一点
Agent:(在上一版基础上修改,只动第三段)
用户:完美
对话修改的核心原则 :保留原文风格,只改建议的部分。
重新生成: 原文 → 丢弃 → 全新文章(风格不可控)
对话修改: 原文 → 保留 → 局部修改(风格延续,精确控制)
对话修改的 Prompt 设计
api/routes/chat.py 的 _regenerate_with_suggestion --- 关键在 System Prompt 的一句话:
swift
system_prompt = (
"你是一位资深自媒体内容编辑。用户会给你一篇已有的文章和修改建议,"
"你需要根据修改建议对文章进行调整,保持文章的整体风格和结构,"
"只修改用户建议的部分。\n\n"
"输出要求:\n"
"1. 第一行输出修改后的标题(以「标题:」开头)\n"
"2. 空一行后输出修改后的完整正文\n"
"3. 正文不要使用 markdown 格式标记\n"
"4. 保持原文的风格、语气和 emoji 使用习惯"
)
注意第3句:"只修改用户建议的部分"。 这句话是整个对话修改的灵魂 --- 它告诉 LLM:你不是在写新文章,你是在编辑已有文章。
对比生成正文时的 Prompt(content/body.py):
bash
# 生成正文:从零创作
"请创作正文内容。"
# 对话修改:在原文基础上编辑
"请根据修改建议调整文章,输出修改后的标题和正文。"
一个是"创作",一个是"编辑" --- 同样是调 LLM,Prompt 的定位完全不同,效果也完全不同。
修改的输入输出
swift
user_prompt = (
f"# 原始标题\n{content.title}\n\n"
f"# 原始正文\n{content.body}\n\n"
f"# 修改建议\n{suggestion}\n\n"
)
erlang
输入:
原始标题:夏季防晒推荐
原始正文:综上所述,夏季防晒需要注意以下几点...
修改建议:太正式了,活泼点,少用书面语
输出:
标题:夏天防晒这几个坑你一定要知道
正文:姐妹们!夏天到了,防晒这事儿真不能马虎...
LLM 同时看到原文和建议 --- 这样它才能"在原文基础上修改",而不是"凭空写一篇新的"。
二、三个数据模型:会话、消息、修改记录
对话式修改的基础是数据模型。chat/schema.py 定义了三个模型,各司其职。
模型关系图
ini
ChatSession(绑定到某篇内容)
├── messages: list[ChatMessage] ← 对话消息列表
│ ├── ChatMessage(role=system) ← 欢迎消息
│ ├── ChatMessage(role=user) ← 用户建议
│ ├── ChatMessage(role=assistant) ← AI 回复
│ └── ...
└── revisions: list[RevisionRecord] ← 修改记录列表
├── RevisionRecord(V1→V2) ← 第一次修改
├── RevisionRecord(V2→V3) ← 第二次修改
└── ...
一个会话绑定一篇内容 --- 每篇内容有自己的对话历史和修改历史,互不干扰。
ChatMessage:对话消息
ini
class MessageRole(str, Enum):
USER = "user" # 用户消息(修改建议)
ASSISTANT = "assistant" # AI 回复(修改后的文章 / 确认信息)
SYSTEM = "system" # 系统消息
class ChatMessage(BaseModel):
id: str = Field(default="", description="消息 ID")
session_id: str = Field(..., description="所属会话 ID")
role: MessageRole = Field(..., description="角色")
content: str = Field(..., description="消息内容")
created_at: datetime = Field(default_factory=datetime.now, description="创建时间")
三种角色,各管一段:
| 角色 | 谁说的 | 内容 |
|---|---|---|
system |
系统 | "已加载文章,你可以提出修改建议" |
user |
用户 | "太正式了,活泼点" |
assistant |
AI | "已根据建议修改,修改记录 #1 已保存..." |
ChatMessage 只存对话文本,不存修改前后的文章全文 --- 文章全文存在 RevisionRecord 里,消息里只存摘要预览。这样对话列表轻量,修改记录完整。
RevisionRecord:修改记录
ini
class RevisionRecord(BaseModel):
id: str = Field(default="", description="记录 ID")
session_id: str = Field(..., description="所属会话 ID")
content_id: str = Field(..., description="关联内容 ID")
suggestion: str = Field(..., description="用户的修改建议")
original_body: str = Field(..., description="修改前正文")
revised_body: str = Field(..., description="修改后正文")
original_title: str = Field(default="", description="修改前标题")
revised_title: str = Field(default="", description="修改后标题")
created_at: datetime = Field(default_factory=datetime.now, description="修改时间")
RevisionRecord 是对话修改的核心 --- 它完整记录了一次修改的"前世今生":
vbnet
suggestion: "太正式了,活泼点" ← 用户说了什么
original_body: "综上所述,夏季防晒..." ← 改之前长什么样
revised_body: "姐妹们!夏天到了..." ← 改之后长什么样
为什么要把原文和修改后文都存下来? --- 三个原因:
- 版本回退:用户觉得改坏了,可以回到上一版
- 偏好提取 :风格进化需要 diff(第9篇讲过),
original_body和revised_body就是 diff 的来源 - 审计追溯:每一步修改都有据可查,知道文章是怎么一步步变成现在的样子
ChatSession:会话
ini
class ChatSession(BaseModel):
id: str = Field(default="", description="会话 ID")
content_id: str = Field(..., description="关联内容 ID")
persona_id: str = Field(default="", description="关联人设 ID")
messages: list[ChatMessage] = Field(default_factory=list, description="消息列表")
revisions: list[RevisionRecord] = Field(default_factory=list, description="修改记录")
created_at: datetime = Field(default_factory=datetime.now, description="创建时间")
updated_at: datetime = Field(default_factory=datetime.now, description="更新时间")
ChatSession 是一个聚合根 --- 它把对话消息和修改记录组织在一起,绑定到一篇内容和一个 人设。
ChatSession
├── content_id → 绑定哪篇文章
├── persona_id → 绑定哪个人设(用于风格进化)
├── messages → 对话历史(轻量,只存文本)
└── revisions → 修改历史(重量,存完整前后文)
为什么 messages 和 revisions 分开存? --- 它们的访问模式不同:
messages:每次打开聊天界面就要全部加载,需要轻量revisions:只在查看修改历史或提取偏好时才加载,可以重量
分开存,各按需加载,互不拖累。
三、修改流程:建议→LLM编辑→版本管理→内容更新
api/routes/chat.py 的 send_message 是整个对话修改的入口 --- 一个请求完成"保存建议→编辑文章→保存记录→更新内容→提取偏好"全流程。
完整流程图
scss
用户发送建议
│
▼
① 获取/创建会话 ──→ 首次修改则创建 ChatSession + 欢迎消息
│
▼
② 保存用户消息 ──→ ChatMessage(role=user)
│
▼
③ LLM 编辑文章 ──→ _regenerate_with_suggestion()
│ 原文 + 建议 → 修改后文
▼
④ 保存修改记录 ──→ RevisionRecord(原文, 修改后文, 建议)
│
▼
⑤ 更新内容 ──→ content.body = revised_body
│ save_content(content)
▼
⑥ AI 回复消息 ──→ ChatMessage(role=assistant)
│
▼
⑦ 自动提取偏好 ──→ StyleLearner.extract_preference_from_revision()
│ 偏好写入人设(第9篇讲过)
▼
⑧ 持久化 ──→ save_chat_session() + _maybe_persist()
8 步,一个请求,完成修改+版本管理+风格进化。 用户只发了一条消息,后台做了这么多事。
代码:send_message 的核心逻辑
ini
# api/routes/chat.py
@router.post("/send", response_model=APIResponse)
async def send_message(req: ChatSendRequest) -> APIResponse:
state = get_state()
# ① 获取原始内容
content = state.repo.store.get_content(req.content_id)
# ② 获取或创建聊天会话
session = state.repo.store.get_chat_session_by_content(req.content_id)
if not session:
session = ChatSession(
content_id=req.content_id,
persona_id=content.persona_id,
)
# 添加系统欢迎消息
welcome = ChatMessage(
session_id=session.id,
role=MessageRole.SYSTEM,
content=f"已加载文章「{content.title}」,你可以提出修改建议...",
)
session.messages.append(welcome)
state.repo.store.save_chat_session(session)
# ③ 保存用户消息
user_msg = ChatMessage(
session_id=session.id,
role=MessageRole.USER,
content=req.message,
)
session.messages.append(user_msg)
# ④ 根据建议重新生成
if req.regenerate:
revised_body, revised_title = await _regenerate_with_suggestion(
content=content,
suggestion=req.message,
config=state.config,
)
# ⑤ 保存修改记录(原文 + 修改后文)
revision = RevisionRecord(
session_id=session.id,
content_id=req.content_id,
suggestion=req.message,
original_body=content.body, # ← 修改前
revised_body=revised_body, # ← 修改后
original_title=content.title,
revised_title=revised_title,
)
session.revisions.append(revision)
# ⑥ 更新内容
content.body = revised_body
if revised_title:
content.title = revised_title
content.compute_word_count()
state.repo.store.save_content(content)
# ⑦ AI 回复消息
ai_msg = ChatMessage(
session_id=session.id,
role=MessageRole.ASSISTANT,
content=f"已根据你的建议修改文章,修改记录 #{len(session.revisions)} 已保存。\n\n"
f"修改后正文预览:\n{revised_body[:200]}...",
)
# ⑧ 自动提取偏好(第9篇讲过,这里不展开)
...
注意 req.regenerate 这个开关 --- 用户可以只提建议不修改(regenerate=False),Agent 会回复"已收到你的建议"。这个设计让对话更灵活:用户可以先提多条建议,最后一次性修改。
请求参数:ChatSendRequest
ini
class ChatSendRequest(BaseModel):
content_id: str = Field(..., description="关联内容 ID")
message: str = Field(..., description="用户消息(修改建议)")
regenerate: bool = Field(default=True, description="是否根据建议重新生成文章")
三个字段,简单明了:
| 字段 | 类型 | 说明 |
|---|---|---|
content_id |
str | 改哪篇文章 |
message |
str | 修改建议(自然语言) |
regenerate |
bool | 是否立即修改(默认 True) |
用户用自然语言提建议,不需要指定改哪里、怎么改 --- LLM 自己理解建议并定位修改位置。这是 Chat as Interface 的核心优势:用户说人话,Agent 干人事。
四、版本链:V1→V2→V3
每次修改产生一条 RevisionRecord,所有修改记录构成一条版本链。
版本链的结构
arduino
V1(原始版本)
│ 建议:"太正式了,活泼点"
▼
V2(第一次修改)
│ 建议:"第三段太长,缩短"
▼
V3(第二次修改)
│ 建议:"加一个 emoji"
▼
V4(第三次修改)
每条 RevisionRecord 记录了:
yaml
RevisionRecord #1: V1 → V2
suggestion: "太正式了,活泼点"
original_body: V1 的正文
revised_body: V2 的正文
RevisionRecord #2: V2 → V3
suggestion: "第三段太长,缩短"
original_body: V2 的正文
revised_body: V3 的正文
RevisionRecord #3: V3 → V4
suggestion: "加一个 emoji"
original_body: V3 的正文
revised_body: V4 的正文
每条记录都是相邻两个版本之间的 diff --- 串起来就是完整的修改历史。
版本链的三个用途
| 用途 | 怎么用 | 对应代码 |
|---|---|---|
| 版本回退 | 取某条的 original_body 恢复 |
GET /chat/revisions/{content_id} |
| 偏好提取 | 从 diff 提取风格偏好 | StyleLearner.extract_preference_from_revision() |
| 审计追溯 | 查看完整修改历史 | session.revisions |
查看修改记录的 API
python
@router.get("/revisions/{content_id}", response_model=APIResponse)
async def get_revisions(content_id: str) -> APIResponse:
"""获取某篇内容的修改记录"""
state = get_state()
session = state.repo.store.get_chat_session_by_content(content_id)
if not session:
return APIResponse(data=[], message="该内容暂无修改记录")
return APIResponse(data=[r.model_dump(mode="json") for r in session.revisions])
一个 GET 请求,拿到完整版本链 --- 前端可以展示修改历史,用户可以对比任意两个版本。
版本号的演进
less
早期:修改#1、修改#2、修改#3 ← 只有序号,不知道改了什么
现在:V1→V2、V2→V3、V3→V4 ← 有方向感,知道是版本演进
从"修改#N"改为"V1→V2" --- 不只是换个写法,是认知的转变:修改不是"打补丁",是"版本演进"。 每次修改都是一个新版本,有完整的前世今生。
五、乐观更新:体验更流畅
什么是乐观更新?
悲观更新:用户发消息 → 等 AI 回复 → 显示用户消息 + AI 回复
乐观更新:用户发消息 → 立即显示用户消息 → 等 AI 回复 → 显示 AI 回复
乐观更新的核心:先显示用户的消息,不等 AI 回复 --- 让用户感觉"消息发出去了",然后在后台等 AI 处理。
为什么需要乐观更新?
用户发建议 → LLM 编辑文章(2-5秒)→ 保存记录 → 提取偏好(又2-5秒)
整个修改流程可能要 5-10 秒 --- 如果用悲观更新,用户点发送后界面卡住 10 秒没有任何反馈,体验极差。
乐观更新让用户立即看到自己的消息,知道"发送成功了",然后耐心等 AI 回复。
后端如何配合?
后端 send_message 是一个同步请求 --- 收到请求,处理完所有步骤,一次性返回。前端配合乐观更新:
php
// 前端伪代码
async function sendMessage(message) {
// 1. 乐观更新:立即显示用户消息
chatMessages.push({ role: "user", content: message });
render();
// 2. 发送请求,等 AI 回复
const response = await fetch("/api/chat/send", {
method: "POST",
body: JSON.stringify({ content_id, message, regenerate: true }),
});
const data = await response.json();
// 3. 用服务器返回的数据替换(包含 AI 回复 + 修改记录)
chatMessages = data.messages;
render();
}
前端先"假装"成功,后端再"真正"处理 --- 两者配合,体验流畅。
AI 回复中包含预览
python
ai_msg = ChatMessage(
session_id=session.id,
role=MessageRole.ASSISTANT,
content=f"已根据你的建议修改文章,修改记录 #{len(session.revisions)} 已保存。\n\n"
f"修改后正文预览:\n{revised_body[:200]}{'...' if len(revised_body) > 200 else ''}",
)
AI 回复不只是"改好了" --- 还包含修改后正文的前 200 字预览。用户不用切到文章页面就能看到修改效果,在聊天界面就能确认"改对了吗"。
revised_body[:200] --- 只取前 200 字,避免消息太长刷屏。要看完整文章,切到内容页面。
六、与风格进化的联动
对话修改不是孤立的 --- 每次修改都会触发风格进化(第9篇讲过),形成闭环。
联动流程
arduino
用户修改文章
│
├──→ 保存 RevisionRecord(原文 + 修改后文)
│
└──→ StyleLearner.extract_preference_from_revision(revision)
│
▼
提取偏好(如"语气:活泼")
│
▼
合并到人设的 style_preferences
│
▼
下次生成自动注入偏好 → 风格越来越准
代码:修改后自动提取偏好
ini
# api/routes/chat.py --- 修改后自动提取偏好
try:
from ...persona.style_learner import StyleLearner
from ...llm.client import LLMClient
llm_for_learn = LLMClient(
base_url=state.config.llm.base_url,
api_key=state.config.llm.api_key,
model=state.config.llm.model,
)
learner = StyleLearner(llm=llm_for_learn, store=state.repo.store)
new_prefs = await learner.extract_preference_from_revision(revision)
if new_prefs:
persona = state.repo.store.get_persona(content.persona_id)
existing = list(persona.style_preferences)
existing.extend(new_prefs)
merged = StyleLearner._merge_preferences(existing)
updated_persona = persona.model_copy(update={
"style_preferences": merged,
"updated_at": datetime.now(),
})
state.repo.store.save_persona(updated_persona)
except Exception as e:
logger.warning(f"即时偏好提取失败(不影响主流程): {e}")
两个关键设计:
- 偏好提取失败不影响主流程 ---
try/except兜底,提取失败只是不进化,不影响修改本身。主流程是修改,进化是附赠。 - 用独立的 LLMClient 实例 --- 偏好提取和文章编辑用同一个 LLM 配置,但创建独立实例,避免状态污染。
完整闭环
arduino
第1次:用户"太正式了,活泼点"
→ 修改文章(V1→V2)
→ 提取偏好"语气:活泼"
→ 写入人设
第2次:生成新文章
→ 自动注入"语气:活泼"
→ 直接活泼风格(不用用户再说)
第3次:用户"emoji少一点"
→ 修改文章(V3→V4)
→ 提取偏好"emoji:低频"
→ 写入人设
第4次:生成新文章
→ 自动注入"语气:活泼 + emoji:低频"
→ 风格越来越精准
对话修改 + 风格进化 = 越用越懂你。 用户每改一次,Agent 就学一点,下次生成更好。这不是两个独立功能,是同一个闭环的两面。
七、错误处理:修改失败怎么办?
LLM 调用可能失败 --- 网络超时、API 限流、输出格式异常。修改流程需要优雅降级。
修改失败的降级
ini
if req.regenerate:
try:
revised_body, revised_title = await _regenerate_with_suggestion(...)
# ... 正常流程
except Exception as e:
logger.error(f"重新生成失败: {e}")
ai_msg = ChatMessage(
session_id=session.id,
role=MessageRole.ASSISTANT,
content=f"抱歉,根据建议重新生成时出错:{e}。请尝试换一种表述方式。",
)
修改失败时:
- 不抛异常给前端(用户看不懂 traceback)
- 返回友好的错误消息("请尝试换一种表述方式")
- 用户消息已保存(不会丢失建议)
- 内容不更新(保持原文不变)
偏好提取失败的降级
python
try:
new_prefs = await learner.extract_preference_from_revision(revision)
# ... 写入人设
except Exception as e:
logger.warning(f"即时偏好提取失败(不影响主流程): {e}")
偏好提取失败时:
- 只记 warning 日志
- 不影响修改结果(文章已经改好了)
- 不影响内容更新(用户已经看到修改后的文章)
- 下次修改再尝试提取
两层降级,各保各的 --- 修改是主流程,必须保;偏好提取是副流程,可以丢。主副分离,互不拖累。
踩坑总结
| 坑 | 根因 | 修复 |
|---|---|---|
| 修改后原文丢失 | 只存修改后的文章 | 加了 RevisionRecord,同时存 original_body 和 revised_body |
| 版本号不直观 | 用"修改#1、修改#2" | 改为"V1→V2、V2→V3",有方向感 |
| 重新生成风格漂移 | Prompt 说"重新生成" | 改为"根据建议调整文章,只修改建议的部分" |
| 修改失败丢建议 | 异常中断整个请求 | 用户消息先保存,修改失败只影响 AI 回复 |
| 偏好提取失败影响修改 | 没有隔离主副流程 | try/except 隔离,偏好提取失败不影响修改 |
| 界面卡顿 | 悲观更新,等 AI 回复才显示 | 乐观更新,先显示用户消息 |
| AI 回复太长刷屏 | 返回完整修改后文章 | 只返回前 200 字预览,revised_body[:200] |
| 对话和修改记录混存 | 都放在 messages 里 | 分开存:messages 存对话文本,revisions 存完整记录 |
| 修改后不进化 | 只改了文章没提取偏好 | 修改后自动调 extract_preference_from_revision |
经验总结
- 对话修改的核心是"编辑"不是"生成" --- Prompt 里"只修改用户建议的部分"这句话,决定了 LLM 是在原文基础上调整,而不是从零写一篇新的
- 三个模型各司其职 ---
ChatMessage存轻量对话文本,RevisionRecord存重量完整记录,ChatSession是聚合根,按需加载互不拖累 - 版本链是修改的"前世今生" --- 每条
RevisionRecord记录相邻版本的 diff,串起来就是完整修改历史,支持回退、偏好提取、审计追溯 - 乐观更新让体验流畅 --- 先显示用户消息再等 AI 回复,5-10 秒的处理时间用户不会觉得卡顿
- 主副流程隔离 --- 修改是主流程必须保,偏好提取是副流程可以丢,
try/except隔离,互不拖累 - 对话修改 + 风格进化 = 越用越懂你 --- 每次修改触发偏好提取,写入人设,下次生成自动注入,形成闭环
下篇预告
下一篇讲 质检系统:Agent的自我审查 --- 质检是 Agent 的"良知",不自检的 Agent 就像没有编辑的报社。多维度质检(口语化、去重、逻辑检查、敏感词)+ orchestrator 编排 + 分数阈值 + 质检与生成的闭环。