先说结论
生成一篇文章只需要一次 LLM 调用,但改好一篇文章往往需要多轮对话。
很多人做内容生成 Agent,生成完就结束了 — 用户不满意只能"重新生成",结果每次都是一篇全新的文章,之前的风格、结构、措辞全丢了。这不是协作,是抽奖。
在 self-media-agent 项目里,chat/模块实现了完整的对话式修改链路:
用户提建议 → LLM 按建议编辑 → 保存修改记录 → 更新内容 → 下次生成自动进化
三个模型,一条链路,让 Agent 从"一次性生成器"变成"可协作的编辑助手"。
| 模型 | 做什么 | 存什么 |
|---|---|---|
ChatSession | 绑定到某篇内容的对话会话 | 消息列表 + 修改记录列表 |
ChatMessage | 一条对话消息 | 角色 + 内容 + 时间 |
RevisionRecord | 一次修改的完整记录 | 原文 + 修改后文 + 建议 |
Chat as Interface — 对话不是聊天,是最高效的人机协作方式。
一、为什么"对话修改"比"重新生成"好?
重新生成的问题
用户:这篇文章太正式了 Agent:(重新生成)→ 全新文章,但风格可能又变了,之前喜欢的部分也没了 用户:不是让你全改,是让你改语气,结构别动 Agent:(重新生成)→ 又是全新文章... 用户:算了,我自己改吧
重新生成的三个致命问题:
| 问题 | 说明 | 后果 |
|---|---|---|
| 风格漂移 | 每次生成都是"从零开始" | 用户刚满意的风格,下次又变了 |
| 上下文丢失 | 不记得上一版改了什么 | 用户重复提同样的要求 |
| 全有或全无 | 要么全接受要么全重来 | 无法局部修改 |
对话修改的优势
用户:这篇文章太正式了,活泼点 Agent:(按建议编辑,保留原文结构和措辞,只调整语气) 用户:很好!但第三段太长了,缩短一点 Agent:(在上一版基础上修改,只动第三段) 用户:完美
对话修改的核心原则:保留原文风格,只改建议的部分。
重新生成: 原文 → 丢弃 → 全新文章(风格不可控) 对话修改: 原文 → 保留 → 局部修改(风格延续,精确控制)
对话修改的 Prompt 设计
api/routes/chat.py的_regenerate_with_suggestion— 关键在 System Prompt 的一句话:
system_prompt = ( "你是一位资深自媒体内容编辑。用户会给你一篇已有的文章和修改建议," "你需要根据修改建议对文章进行调整,保持文章的整体风格和结构," "只修改用户建议的部分。\n\n" "输出要求:\n" "1. 第一行输出修改后的标题(以「标题:」开头)\n" "2. 空一行后输出修改后的完整正文\n" "3. 正文不要使用 markdown 格式标记\n" "4. 保持原文的风格、语气和 emoji 使用习惯" )
注意第3句:"只修改用户建议的部分"。这句话是整个对话修改的灵魂 — 它告诉 LLM:你不是在写新文章,你是在编辑已有文章。
对比生成正文时的 Prompt(content/body.py):
# 生成正文:从零创作 "请创作正文内容。" # 对话修改:在原文基础上编辑 "请根据修改建议调整文章,输出修改后的标题和正文。"
一个是"创作",一个是"编辑"— 同样是调 LLM,Prompt 的定位完全不同,效果也完全不同。
修改的输入输出
user_prompt = ( f"# 原始标题\n{content.title}\n\n" f"# 原始正文\n{content.body}\n\n" f"# 修改建议\n{suggestion}\n\n" )输入: 原始标题:夏季防晒推荐 原始正文:综上所述,夏季防晒需要注意以下几点... 修改建议:太正式了,活泼点,少用书面语 输出: 标题:夏天防晒这几个坑你一定要知道 正文:姐妹们!夏天到了,防晒这事儿真不能马虎...
LLM 同时看到原文和建议— 这样它才能"在原文基础上修改",而不是"凭空写一篇新的"。
二、三个数据模型:会话、消息、修改记录
对话式修改的基础是数据模型。chat/schema.py定义了三个模型,各司其职。
模型关系图
ChatSession(绑定到某篇内容) ├── messages: list[ChatMessage] ← 对话消息列表 │ ├── ChatMessage(role=system) ← 欢迎消息 │ ├── ChatMessage(role=user) ← 用户建议 │ ├── ChatMessage(role=assistant) ← AI 回复 │ └── ... └── revisions: list[RevisionRecord] ← 修改记录列表 ├── RevisionRecord(V1→V2) ← 第一次修改 ├── RevisionRecord(V2→V3) ← 第二次修改 └── ...
一个会话绑定一篇内容— 每篇内容有自己的对话历史和修改历史,互不干扰。
ChatMessage:对话消息
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:修改记录
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 是对话修改的核心— 它完整记录了一次修改的"前世今生":
suggestion: "太正式了,活泼点" ← 用户说了什么 original_body: "综上所述,夏季防晒..." ← 改之前长什么样 revised_body: "姐妹们!夏天到了..." ← 改之后长什么样
为什么要把原文和修改后文都存下来?— 三个原因:
版本回退:用户觉得改坏了,可以回到上一版
偏好提取:风格进化需要 diff(第9篇讲过),
original_body和revised_body就是 diff 的来源审计追溯:每一步修改都有据可查,知道文章是怎么一步步变成现在的样子
ChatSession:会话
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是整个对话修改的入口 — 一个请求完成"保存建议→编辑文章→保存记录→更新内容→提取偏好"全流程。
完整流程图
用户发送建议 │ ▼ ① 获取/创建会话 ──→ 首次修改则创建 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 的核心逻辑
# 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
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,所有修改记录构成一条版本链。
版本链的结构
V1(原始版本) │ 建议:"太正式了,活泼点" ▼ V2(第一次修改) │ 建议:"第三段太长,缩短" ▼ V3(第二次修改) │ 建议:"加一个 emoji" ▼ V4(第三次修改)
每条RevisionRecord记录了:
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
@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 请求,拿到完整版本链— 前端可以展示修改历史,用户可以对比任意两个版本。
版本号的演进
早期:修改#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是一个同步请求 — 收到请求,处理完所有步骤,一次性返回。前端配合乐观更新:
// 前端伪代码 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 回复中包含预览
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篇讲过),形成闭环。
联动流程
用户修改文章 │ ├──→ 保存 RevisionRecord(原文 + 修改后文) │ └──→ StyleLearner.extract_preference_from_revision(revision) │ ▼ 提取偏好(如"语气:活泼") │ ▼ 合并到人设的 style_preferences │ ▼ 下次生成自动注入偏好 → 风格越来越准
代码:修改后自动提取偏好
# 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 配置,但创建独立实例,避免状态污染。
完整闭环
第1次:用户"太正式了,活泼点" → 修改文章(V1→V2) → 提取偏好"语气:活泼" → 写入人设 第2次:生成新文章 → 自动注入"语气:活泼" → 直接活泼风格(不用用户再说) 第3次:用户"emoji少一点" → 修改文章(V3→V4) → 提取偏好"emoji:低频" → 写入人设 第4次:生成新文章 → 自动注入"语气:活泼 + emoji:低频" → 风格越来越精准
对话修改 + 风格进化 = 越用越懂你。用户每改一次,Agent 就学一点,下次生成更好。这不是两个独立功能,是同一个闭环的两面。
七、错误处理:修改失败怎么办?
LLM 调用可能失败 — 网络超时、API 限流、输出格式异常。修改流程需要优雅降级。
修改失败的降级
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)
返回友好的错误消息("请尝试换一种表述方式")
用户消息已保存(不会丢失建议)
内容不更新(保持原文不变)
偏好提取失败的降级
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 编排 + 分数阈值 + 质检与生成的闭环。