0. 前置推荐阅读
- 了解什么是ActionNode:【AI的未来 - AI Agent系列】【MetaGPT】4. ActionNode从理论到实战
- 了解如何用MetaGPT写一篇完整的文档:
- 【AI的未来 - AI Agent系列】【MetaGPT】5. 更复杂的Agent实战 - 实现技术文档助手
- 【AI的未来 - AI Agent系列】【MetaGPT】6. 用ActionNode重写技术文档助手
更多 MetaGPT 实战可看这里:https://blog.csdn.net/attitude93/category_12546719.html?spm=1001.2014.3001.5482
1. 本文内容
本文是 《MetaGPT智能体开发入门》课程的第5个任务:用ActionNode完成短篇小说写作(爽文/科幻/言情)。
2. 需求分析
2.1 步骤拆解
写小说与写技术文章类似,所以总体思路我们可以参考之前的技术文档助手:【AI的未来 - AI Agent系列】【MetaGPT】6. 用ActionNode重写技术文档助手,即先生成目录,然后根据目录详细写每一章的内容。
但是小说与技术文章有几个不同点:
- 小说有故事情节、人物等要素
- 各章节之间要保持故事的连贯性
- … 其它可能没想到的
保持连贯性,其实就是在prompt中加入上下文信息,也就是多使用点记忆里的东西。
因为咱也没写过小说,也没用prompt在chatgpt上写过小说,于是我从网上找了一个相对来讲比较系统的写小说的prompt:
ChatGPT魔法课堂进阶:通过Prompt提示模板写本自己的小说
总结出通过Prompt提示写小说的几个步骤:
(1)生成小说的基本要素:标题、主角、反派、情节设置、中心主题等
(2)根据第一步生成的基本要素,生成小说的章节目录
(3)根据小说的章节目录,生成每个章节的故事简介
(4)根据每个章节的故事简介,生成每个章节的详细故事内容
参考以上步骤,我觉得可以将2、3步合并,直接一起生成章节目录和故事简介,即:
(1)生成小说的基本要素:标题、主角、反派、情节设置、中心主题等
(2)根据第一步生成的基本要素,生成小说的章节目录和对应的故事简介
(4)根据每个章节的故事简介,生成每个章节的详细故事内容
2.2 流程设计
简单画了下,凑活看… 解释下:
(1)生成小说基本要素的ActionNode为STRUCT_WRITE
(2)生成小说目录的ActionNode为DIRECTORY_WRITE
(3)这两个ActionNode作为子节点,包裹在父节点 OUTLINE_WRITE_NODES中
(4)父节点OUTLINE_WRITE_NODES包裹在WriteStroyStruct Action中(ActionNode必须在Action中运行)
(5)执行完WriteStroyStruct Action就完成了前两个步骤,得到了章节目录和故事简介。
(6)使用_handle_directory函数分解章节目录和故事简介,生成一系列WriteContent Action
(7)WriteContent Action中使用STORY_WRITE ActionNode来写详细信息
3. 写代码
3.1 生成小说基本要素的ActionNode:STRUCT_WRITE
STRUCT_WRITE_INSTRUCTION = """ 您现在是短篇小说领域经验丰富的小说作家, 我们需要您根据给定的小说题材生成故事的基本结构。 按照以下内容输出符合给定题材的小说基本结构: 标题:"小说的标题" 设置:"小说的情景设置细节,包括时间段、地点和所有相关背景信息" 主角:"小说主角的名字、年龄、职业,以及他们的性格和动机、简要的描述" 反派角色:"小说反派角色的名字、年龄、职业,以及他们的性格和动机、简要的描述" 冲突:"小说故事的主要冲突,包括主角面临的问题和涉及的利害关系" 对话:"以对话的形式描述情节,揭示人物,以此提供一些提示给读者" 主题:"小说中心主题,并说明如何在整个情节、角色和背景中展开" 基调:"整体故事的基调,以及保持背景和人物的一致性和适当性的说明" 节奏:"调节故事节奏以建立和释放紧张气氛,推进情节,创造戏剧效果的说明" 其它:"任何额外的细节或对故事的要求,如特定的字数或题材限制" """ # ActionNode,写小说基本结构 STRUCT_WRITE = ActionNode( # ActionNode的名称 key="Struct Write", # 期望输出的格式 expected_type=str, # 命令文本 instruction=STRUCT_WRITE_INSTRUCTION, # 例子输入,在这里我们可以留空 example="", )
3.2 生成目录的ActionNode:DIRECTORY_WRITE
DIRECTORY_WRITE_INSTRUCTION = """ 您现在是短篇小说领域经验丰富的小说作家。我们需要您根据context的内容创作出小说的目录和章节内容概况。 请按照以下要求提供该小说的具体目录和目录中的故事概况: 1. 输出必须严格符合指定语言。 2. 回答必须严格按照字典格式,如: {"title": "xxx", "directory": {"第一章:章节标题": "故事概况", "第二章:章节标题": "故事概况"}} 。 3. 目录应尽可能引人注目和充分,包括一级目录和本章故事概况。 4. 不要有额外的空格或换行符。 """ # ActionNode,生成小说目录 DIRECTORY_WRITE = ActionNode( # ActionNode的名称 key="Directory Write", # 期望输出的格式 expected_type=str, # 命令文本 instruction=DIRECTORY_WRITE_INSTRUCTION, # 例子输入,在这里我们可以留空 example="", )
注意:这里我们规定了返回的内容必须按照字典格式,否则校验不通过,程序报错,这是为了后面的_handle_directory处理。
3.3 生成章节详细内容的ActionNode:STORY_WRITE
# ActionNode,写小说章节内容 STORY_WRITE = ActionNode( # ActionNode的名称 key="Story Write", # 期望输出的格式 expected_type=str, # 命令文本 instruction="您现在是短篇小说领域经验丰富的小说作家。我们需要您创作出小说详细内容。", # 例子输入,在这里我们可以留空 example="", )
3.4 父节点:OUTLINE_WRITE_NODES
class OUTLINE_WRITE_NODES(ActionNode): def __init__(self, name="Outline Nodes", expected_type=str, instruction="", example=""): super().__init__(key=name, expected_type=expected_type, instruction=instruction, example=example) self.add_children([STRUCT_WRITE, DIRECTORY_WRITE]) # 初始化过程,将上面实现的两个子节点加入作为OUTLINE_WRITE_NODES类的子节点 async def fill(self, context, llm, schema="raw", mode="auto", strgy="complex"): self.set_llm(llm) self.set_context(context) if self.schema: schema = self.schema if strgy == "simple": return await self.simple_fill(schema=schema, mode=mode) elif strgy == "complex": # 这里隐式假设了拥有children child_context = context # 输入context作为第一个子节点的context for _, i in self.children.items(): i.set_context(child_context) # 为子节点设置context child = await i.simple_fill(schema=schema, mode=mode) child_context = child.content # 将返回内容(child.content)作为下一个子节点的context self.content = child_context # 最后一个子节点返回的内容设置为父节点返回内容(self.content) return self
3.5 父节点:CONTENT_WRITE_NODES
class CONTENT_WRITE_NODES(ActionNode): def __init__(self, name="Content Nodes", expected_type=str, instruction="", example=""): super().__init__(key=name, expected_type=expected_type, instruction=instruction, example=example) self.add_children([STORY_WRITE]) ## 这里只初始化了一个子节点 async def fill(self, context, llm, schema="raw", mode="auto", strgy="complex"): self.set_llm(llm) self.set_context(context) if self.schema: schema = self.schema if strgy == "simple": return await self.simple_fill(schema=schema, mode=mode) elif strgy == "complex": # 这里隐式假设了拥有children child_context = context # 输入context作为第一个子节点的context for _, i in self.children.items(): i.set_context(child_context) # 为子节点设置context child = await i.simple_fill(schema=schema, mode=mode) child_context = child.content # 将返回内容(child.content)作为下一个子节点的context self.content = child_context # 最后一个子节点返回的内容设置为父节点返回内容(self.content) return self
3.6 小说要素和目录生成Action:WriteStoryStruct
class WriteStoryStruct(Action): language: str = "Chinese" def __init__(self, name: str = "", language: str = "Chinese", *args, **kwargs): super().__init__() self.language = language self.node = OUTLINE_WRITE_NODES() ## 包裹住ActionNodes async def run(self, topic: str, *args, **kwargs) -> Dict: DIRECTORY_PROMPT = """ 小说的题材是{topic}。请严格使用{language}语言,并按照instruction中的说明内容输出内容 """ # 我们设置好prompt,作为ActionNode的输入 prompt = DIRECTORY_PROMPT.format(topic=topic, language=self.language) # 该方法会返回self,也就是一个ActionNode对象 resp_node = await self.node.fill(context=prompt, llm=self.llm, schema="raw") # 选取ActionNode.content,获得我们期望的返回信息 resp = resp_node.content # return Message(content=resp) # 返回Message对象后,会在resp前面加上 user: 字样,会破坏Dict结构 return OutputParser.extract_struct(resp, dict)
3.7 小说章节详细内容生成Action:WriteContent
class WriteContent(Action): language: str = "Chinese" directory: str = "" content: str = "" def __init__(self, name: str = "", directory: str = "", content: str = "", language: str = "Chinese", *args, **kwargs): super().__init__() self.language = language self.directory = directory self.content = content self.node = CONTENT_WRITE_NODES() async def run(self, topic: str, *args, **kwargs) -> str: CONTENT_PROMPT = """ 您现在是短篇小说领域经验丰富的小说作家。请以引人入胜的风格,深入细致地按照故事概况"{content}"写出故事内容,注意与上一章故事内容的衔接。 上一章故事内容为{topic}。 """ n = 0 ## 这里是因为大模型经常返回“抱歉,无法满足您的要求”,不给生成内容...... ## 所以加了个重试,有效果,但效果不佳。我白嫖的3.5,用更好的大模型可能没有这个问题...... while n < 5: prompt = CONTENT_PROMPT.format( topic=topic, language=self.language, content=self.content) resp_node = await self.node.fill(context=prompt, schema="raw", llm=self.llm) resp = resp_node.content n += 1 if (resp.find("无法满足") == -1 or resp.find("无法完成") == -1): break resp = f"## {self.directory}\n\n{resp}" ## 返回时要加上标题 return resp
3.8 小说助手Role:StoryAssistant
class StoryAssistant(Role): topic: str = "" total_content: str = "" language: str = "Chinese" main_title: str = "" def __init__( self, name: str = "Story Assistant", profile: str = "Story Assistant", language: str = "Chinese", ): super().__init__() self._init_actions([WriteStoryStruct(language=language)]) ## 先加入WriteStoryStruct Action self.language = language async def _think(self) -> None: """Determine the next action to be taken by the role.""" logger.info(self.rc.state) if self.rc.todo is None: self._set_state(0) return if self.rc.state + 1 < len(self.states): self._set_state(self.rc.state + 1) else: self.rc.todo = None async def _handle_directory(self, titles: Dict) -> Message: self.main_title = titles.get("title") directory = f"{self.main_title}\n" self.total_content += f"# {self.main_title}" actions = list() total_dir = titles.get("directory") for first_dir in total_dir: directory += f"- {first_dir}\n" dir_content = total_dir[first_dir] actions.append(WriteContent( language=self.language, directory=first_dir, content=dir_content)) ## 根据每章节目录,生成一系列WriteContent Action self._init_actions(actions) self.rc.todo = None return Message(content=directory) content_count: int = 0 async def _act(self) -> Message: """Perform an action as determined by the role. Returns: A message containing the result of the action. """ time.sleep(20) todo = self.rc.todo if type(todo) is WriteStoryStruct: msg = self.rc.memory.get(k=1)[0] self.topic = msg.content resp = await todo.run(topic=self.topic) logger.info(resp) return await self._handle_directory(resp) self.content_count += 1 msg = self.rc.memory.get(k=1)[0] ## 获取上章的内容,为prompt提供上下文信息,使章节之间的故事有连贯性 if self.content_count > 1: if len(msg.content) > 100: resp = await todo.run(topic=msg.content[-100]) ## 我这里取的是上章内容最后100个字符 else: resp = await todo.run(topic=msg.content) else: resp = await todo.run(topic=self.main_title) ## 第一章内容生成之前,没有上文,所以这里传个小说题目进去,当作上下文 if self.total_content != "": self.total_content += "\n\n\n" self.total_content += resp ## 组装结果 msg_last = Message(content=str(resp), role=self.profile) self.rc.memory.add(msg_last) ## 将本章内容加入记忆中,以便下章作为上下文使用,可以在一定程度上保证上下文连贯性 return msg_last async def _react(self) -> Message: while True: await self._think() if self.rc.todo is None: break msg = await self._act() root_path = TUTORIAL_PATH / datetime.now().strftime("%Y-%m-%d_%H-%M-%S") logger.info(f"Write tutorial to {root_path}") await File.write(root_path, f"{self.main_title}.md", self.total_content.encode('utf-8')) return msg
3.9 完整代码,运行
上文中的代码基本就全了,再加上引入包和main函数,就能跑了。模拟用户输入的小说题材是“科幻”。
import asyncio import re import time from typing import Dict from metagpt.actions.action import Action from metagpt.actions.action_node import ActionNode from metagpt.logs import logger from metagpt.roles import Role from metagpt.schema import Message from metagpt.utils.common import OutputParser from metagpt.const import TUTORIAL_PATH from datetime import datetime from metagpt.utils.file import File ........ 把上面的代码依次放在这 async def main(): msg = "科幻" role = StoryAssistant() logger.info(msg) result = await role.run(msg) logger.info(result) asyncio.run(main())
4. 运行结果及不足(优化点)
4.1 运行结果
4.2 不足及优化点
(1)结果的不足
仔细读一下它生成的小说,可以发现是具有上下文连贯性的,但是还是比较差,不够顺滑。要想获得更好的结果,需要继续优化Prompt和传入的上下文等。或者我觉得还可以将以上文本不按章节分块,然后在对每块内容丢给大模型进行语言优化,包括上下文连贯性、去掉总结性的语句等。
(2)看过 【AI的未来 - AI Agent系列】【MetaGPT】6. 用ActionNode重写技术文档助手 的同学可能注意到了,我这里WriteContent结构用的是下图这种,其实没有体现出ActionNode的作用:
可以将其优化成上篇文章中的结构:
参考着改起来比较简单,我就不再赘述了。有兴趣的同学可以去尝试改一改。
4.3 其它思考
通过这个小说助手的实战,感觉MetaGPT的使用都不是最大的障碍了,最大的障碍是写小说的Prompt和标准流程SOP…
知道了SOP,也就知道了整体步骤。其它的无非就是优化prompt,该给大模型什么样的输入,返回的结果要不要处理,要不要给下个动作使用等。
你觉得呢?