对话式修改:Agent的人机协作模式 — Chat as Interface,对话不是聊天,是最高效的人机协作方式

先说结论

生成一篇文章只需要一次 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:  "姐妹们!夏天到了..."        ← 改之后长什么样

为什么要把原文和修改后文都存下来? --- 三个原因:

  1. 版本回退:用户觉得改坏了,可以回到上一版
  2. 偏好提取 :风格进化需要 diff(第9篇讲过),original_bodyrevised_body 就是 diff 的来源
  3. 审计追溯:每一步修改都有据可查,知道文章是怎么一步步变成现在的样子

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   → 修改历史(重量,存完整前后文)

为什么 messagesrevisions 分开存? --- 它们的访问模式不同:

  • messages:每次打开聊天界面就要全部加载,需要轻量
  • revisions:只在查看修改历史或提取偏好时才加载,可以重量

分开存,各按需加载,互不拖累。

三、修改流程:建议→LLM编辑→版本管理→内容更新

api/routes/chat.pysend_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}")

两个关键设计

  1. 偏好提取失败不影响主流程 --- try/except 兜底,提取失败只是不进化,不影响修改本身。主流程是修改,进化是附赠。
  2. 用独立的 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_bodyrevised_body
版本号不直观 用"修改#1、修改#2" 改为"V1→V2、V2→V3",有方向感
重新生成风格漂移 Prompt 说"重新生成" 改为"根据建议调整文章,只修改建议的部分"
修改失败丢建议 异常中断整个请求 用户消息先保存,修改失败只影响 AI 回复
偏好提取失败影响修改 没有隔离主副流程 try/except 隔离,偏好提取失败不影响修改
界面卡顿 悲观更新,等 AI 回复才显示 乐观更新,先显示用户消息
AI 回复太长刷屏 返回完整修改后文章 只返回前 200 字预览,revised_body[:200]
对话和修改记录混存 都放在 messages 里 分开存:messages 存对话文本,revisions 存完整记录
修改后不进化 只改了文章没提取偏好 修改后自动调 extract_preference_from_revision

经验总结

  1. 对话修改的核心是"编辑"不是"生成" --- Prompt 里"只修改用户建议的部分"这句话,决定了 LLM 是在原文基础上调整,而不是从零写一篇新的
  2. 三个模型各司其职 --- ChatMessage 存轻量对话文本,RevisionRecord 存重量完整记录,ChatSession 是聚合根,按需加载互不拖累
  3. 版本链是修改的"前世今生" --- 每条 RevisionRecord 记录相邻版本的 diff,串起来就是完整修改历史,支持回退、偏好提取、审计追溯
  4. 乐观更新让体验流畅 --- 先显示用户消息再等 AI 回复,5-10 秒的处理时间用户不会觉得卡顿
  5. 主副流程隔离 --- 修改是主流程必须保,偏好提取是副流程可以丢,try/except 隔离,互不拖累
  6. 对话修改 + 风格进化 = 越用越懂你 --- 每次修改触发偏好提取,写入人设,下次生成自动注入,形成闭环

下篇预告

下一篇讲 质检系统:Agent的自我审查 --- 质检是 Agent 的"良知",不自检的 Agent 就像没有编辑的报社。多维度质检(口语化、去重、逻辑检查、敏感词)+ orchestrator 编排 + 分数阈值 + 质检与生成的闭环。

相关推荐
dong_junshuai5 小时前
每天一个开源项目#99 OpenResearch:2.2K星的本地研究Agent工作台
开源·github·agent
花椒技术6 小时前
把 AI 视频接进直播:提前生成、按次生成、持续生成怎么选?
agent·音视频开发
用户8082598666876 小时前
用 LangGraph 重写自己手写的 Agent:框架替你解决了什么,什么它不管
agent
能不能静下心来看6 小时前
从 RAG 到 Agent:跑通两个 Demo 后,我终于分清了这两个词
agent
ZGi.ai6 小时前
知识库权限变了,怎样避免答错?
agent·权限管理·知识库·工作流·zgi·客服运营
大模型真好玩6 小时前
DeepSeek Harness 入门很简单(四)——DeepSeek Harness接入插件
人工智能·agent·deepseek
掰头战士6 小时前
MCP、Skill、Plugin,都是给agent拓展能力,到底有何区别?
typescript·llm·agent
嘟嘟嘟95278 小时前
AI Agent 的边缘困境
人工智能·架构·agent
HYDtomako8 小时前
Agent checkpoint设计
ai·agent·checkpoint·claude code
梦在远山后8 小时前
从需求到架构:我的企业研发 Agent 整体设计
langchain·agent