以报时机器人为例详细介绍tracker_store和event_broker
报时机器人源码参考[1][2],本文重点介绍当 tracker_store 类型为 SQL 时,events 表的表结构以及数据是如何生成的。以及当 event_broker 类型为 SQL 时,events 表的表结构以及数据是如何生成的。
一.报时机器人启动 [3]
Rasa 对话系统启动方式详见参考文献[3]中执行程序部分,这里不再赘述。如下所示:
1.加载模型:rasa run --cors "*"
2.运行 action server:rasa run actions
3.运行 Web 页面:python -m http.server 8080
二.(tracker_store)endpoints.yml 和 events 表结构
使用 mysql 数据库来存储对话,使用 MySQL 新建 db 为 rasa_tracker_store,其中字符集和排序规则分别为 utf8mb4 -- UTF-8 Unicode 和 utf8mb4_0900_ai_ci。
1.endpoints.yml 配置文件
tracker_store:
type: SQL
dialect: "mysql+pymysql"
url: "localhost" # your mysql host
db: "rasa_tracker_store" # name of the mysql database
username: "root" # username to access the database
password: "root" # password to access the database
2.新建数据库 rasa_tracker_store

3.events 表结构

(1)id:主键 id
(2)sender_id:发送者 id
(3)type_name:event 的类型名字
(4)timestamp:时间戳
(5)intent_name:意图名字
(6)action_name:action 名字
(7)data:数据
4.events 表数据内容
以用户问:"今天星期几",机器答:"星期三"为例子展开介绍。

生成的 rasa_tracker_store.events 数据表内容,如下所示:

(1)id:主键 id。
这个自增主键就不用多说了。
(2)sender_id:发送者 id。
比如,oCMNVZ44YCbHIcFYAAAB。这个数据是如何来的?生成的规则是什么呢?
sender_id 是在 SQLTrackerStore 类中的 SQLEvent 子类中初始化的。SQLEvent 子类是 SQLAlchemy 的一部分,用于在数据库中创建一个表。在这个表中,sender_id 是一个字段,它的类型是字符串(最大长度为 255),并且它被设置为非空(nullable=False),并且为其创建了索引(index=True)。这意味着在数据库中,sender_id 字段不能为 null,并且可以被快速查找。
sender_id 是在创建 DialogueStateTracker 对象时传入的一个参数,它通常用于标识对话的发送者。在 Rasa 中,每个对话都有一个唯一的 sender_id,这样可以区分不同的用户会话。在 from_events 类方法中,sender_id 是作为第一个参数传入的。这个方法用于从一系列事件中创建一个 DialogueStateTracker 对象。这些事件会被应用到新的跟踪器上,以重建其状态。生成 sender_id 的具体规则取决于你的应用,当客户端是 Rasa Shell、Rasa X、HTTP API 等的时候,都不相同。由于本次使用的是 Socket 方式,可以顺藤摸瓜去找 sender_id 的具体生成规则,这里不再细节展开。rasa/core/channels/socketio.py 如下所示:

(3)type_name:event 的类型名字。
比如,action、session_started、user、slot、user_featurization、bot。这个数据是什么?除了这个数据还有其它的类型名字吗?(列出全部)。rasa/shared/core/events.py 如下所示:
| 序号 | 事件类 | 事件类型名字 | 备注 |
|---|---|---|---|
| 1 | Event(ABC)类 | "event" | 描述对话中的事件以及它们如何影响对话状态。用户与助手进行对话期间发生的所有事情的不可变表示。告诉 rasa.shared.core.trackers.DialogueStateTracker 如何在事件发生时更新其状态。 |
| 2 | UserUttered(Event) | "user" | 用户对机器人说了些什么。作为副作用,将在 Tracker 中创建一个新的 Turn。 |
| 3 | DefinePrevUserUtteredFeaturization(SkipEventInMDStoryMixin) | "user_featurization" | 存储 action 是基于文本还是意图预测的信息。 |
| 4 | EntitiesAdded(SkipEventInMDStoryMixin) | "entities" | 用于将提取的实体添加到 tracker 状态的事件。 |
| 5 | BotUttered(SkipEventInMDStoryMixin) | "bot" | 机器人对用户说了些什么。此类在故事训练中不使用,因为它包含在 ActionExecuted 类中。在 Tracker 中进行了记录。 |
| 6 | SlotSet(Event) | "slot" | 用户已指定其对 slot 值的偏好。每个 slot 都有一个名称和一个值。此事件可用于在对话中设置 slot 的值。作为副作用,Tracker 的插槽将被更新,以便 tracker.slots[key]=value。 |
| 7 | Restarted(AlwaysEqualEventMixin) | "restart" | 对话应该重新开始,历史记录被擦除。与删除所有事件不同,可以使用此事件来重置跟踪器状态(例如,忽略任何过去的用户消息并重置所有插槽)。 |
| 8 | UserUtteranceReverted(AlwaysEqualEventMixin) | "rewind" | 机器人会撤消最近的用户消息之前的所有内容。机器人将撤消最新的 UserUttered 之后的所有事件,这也意味着跟踪器上的最后一个事件通常是 action_listen,机器人正在等待新的用户消息。 |
| 9 | AllSlotsReset(AlwaysEqualEventMixin) | "reset_slots" | 所有插槽都重置为其初始值。如果要保留对话历史记录并仅重置插槽,则可以使用此事件将所有插槽设置为其初始值。 |
| 10 | ReminderScheduled(Event) | "reminder" | 在给定时间安排异步触发用户意图。如果需要,触发的意图可以包括实体。 |
| 11 | ReminderCancelled(Event) | "cancel_reminder" | 取消某些工作。 |
| 12 | ActionReverted(AlwaysEqualEventMixin) | "undo" | 机器人撤消了最后的操作。机器人会撤消最近的操作之前的所有内容。这包括操作本身以及操作创建的任何事件,例如设置插槽事件-机器人现在将使用最近操作之前的状态来预测新操作。 |
| 13 | StoryExported(Event) | "export" | 故事应该转储到文件。 |
| 14 | FollowupAction(Event) | "followup" | 排队后续操作。 |
| 15 | ConversationPaused(AlwaysEqualEventMixin) | "pause" | 忽略用户的消息,让人类接管。作为副作用,Tracker 的 paused 属性将被设置为 True。 |
| 16 | ConversationResumed(AlwaysEqualEventMixin) | "resume" | 机器人接管对话。PauseConversation 的反义词。作为副作用,Tracker 的 paused 属性将被设置为 False。 |
| 17 | ActionExecuted(Event) | "action" | 操作描述了执行的操作 + 其结果。它包括一个操作和一个事件列表。操作将附加到 Tracker.turns 中的最新 Turn。 |
| 18 | AgentUttered(SkipEventInMDStoryMixin) | "agent" | agent 对用户说了些什么。由于它包含在 ActionExecuted 类中,因此此类在故事训练中不使用。在 Tracker 中进行了条目。 |
| 19 | ActiveLoop(Event) | "active_loop" | 如果给出了 name:使用 name 激活循环,否则停用活动循环。 |
| 20 | LegacyForm(ActiveLoop) | "form" | 旧 Form 事件的旧版处理程序。ActiveLoop 事件曾被称为 Form。这个类是为了处理旧的遗留事件,这些事件是使用旧的类型名称 form 存储的。 |
| 21 | LoopInterrupted(SkipEventInMDStoryMixin) | "loop_interrupted" | FormPolicy 和 RulePolicy 添加的事件。通知表单操作是否验证用户输入。 |
| 22 | LegacyFormValidation(LoopInterrupted) | "form_validation" | 旧 FormValidation 事件的旧版处理程序。LoopInterrupted 事件曾被称为 FormValidation。这个类是为了处理旧的遗留事件,这些事件是使用旧的类型名称 form_validation 存储的。 |
| 23 | ActionExecutionRejected(SkipEventInMDStoryMixin) | "action_execution_rejected" | 通知 Core 操作的执行已被拒绝。 |
| 24 | SessionStarted(AlwaysEqualEventMixin) | "session_started" | 标记新会话会话的开始。 |
(4)timestamp:Unix 时间戳。
比如,1704300000,转换后的日期时间:2024-01-03 16:40:00。Unix 时间戳是指从 1970 年 1 月 1 日 00:00:00UTC(协调世界时)开始的秒数。
from datetime import datetime
timestamp = 1704300000
date_object = datetime.utcfromtimestamp(timestamp)
print("转换后的日期时间:", date_object)
(5)intent_name:意图名字
比如,query_weekday。报时机器人总共的意图包括 greet、goodbye、query_time、query_date、query_weekday。对应的例子如下所示:
version: "3.0"
nlu:
- intent: greet
examples: |
- 你好
- 您好
- hello
- hi
- 喂
- 在么
- intent: goodbye
examples: |
- 拜拜
- 再见
- 拜
- 退出
- 结束
- intent: query_time
examples: |
- 现在几点了
- 什么时候了
- 几点了
- 现在什么时候了
- 现在的时间
- intent: query_date
examples: |
- [今天](date)几号
- [今天](date)是几号
- [昨天](date)几号
- [明天](date)几号
- [今天](date)的日期
- [今天](date)几号了
- [明天](date)的日期
- 几号
- intent: query_weekday
examples: |
- [今天](date)星期几
- [明天](date)星期几
- [昨天](date)星期几
- [今天](date)是星期几
- 星期几
(6)action_name:action 名字
比如,action_session_start(会话开始)、action_listen(机器人处于监听状态,机器人每次回答完毕后都会处于监听状态)、date(日期实体)、action_query_weekday(自定义 action)。除此之外,还有哪些 action_name 呢?(列出全部)。rasa/core/actions/action.py 如下所示:
| 序号 | 动作类 | 动作名字 | 备注 |
|---|---|---|---|
| 1 | Action | NotImplementedError | 响应对话状态的下一个操作。 |
| 2 | ActionBotResponse(Action) | —— | 一个动作,其唯一效果是在运行时发出响应。 |
| 3 | ActionEndToEndResponse(Action) | —— | 动作以端到端响应向用户发出响应。 |
| 4 | ActionRetrieveResponse(ActionBotResponse) | —— | 查询响应选择器以获取适当的响应的操作。 |
| 5 | ActionBack(ActionBotResponse) | "action_back" | 将跟踪器状态恢复两个用户话语。 |
| 6 | ActionListen(Action) | "action_listen" | 任何回合中的第一个动作-机器人等待用户消息。机器人应停止采取进一步的操作,并等待用户说些什么。 |
| 7 | ActionRestart(ActionBotResponse) | "action_restart" | 将跟踪器重置为其初始状态。如果可用,则发出重启响应。 |
| 8 | ActionSessionStart(Action) | "action_session_start" | 应用一个对话会话开始,将上一个会话中的所有 SlotSet 事件应用于新会话。 |
| 9 | ActionDefaultFallback(ActionBotResponse) | "action_default_fallback" | 执行回退操作并返回对话的上一个状态。 |
| 10 | ActionDeactivateLoop(Action) | "action_deactivate_loop" | 停用活动循环。 |
| 11 | RemoteAction(Action) | —— | —— |
| 12 | ActionRevertFallbackEvents(Action) | "action_revert_fallback_events" | 撤消 TwoStageFallbackPolicy 期间完成的事件。这将撤消在 TwoStageFallbackPolicy 的回退期间完成的用户消息和机器人话语。通过这样做,不需要为不同的路径编写自定义故事,而只需要编写快乐的路径。这已被弃用,一旦删除 TwoStageFallbackPolicy,就可以删除它。 |
| 13 | ActionUnlikelyIntent(Action) | "action_unlikely_intent" | 一个动作,指示 NLU 预测的意图是意外的。此操作可以由 UnexpecTEDIntentPolicy 预测。 |
| 14 | ActionDefaultAskAffirmation(Action) | "action_default_ask_affirmation" | 默认实现,询问用户确认他的意图。建议使用自定义操作覆盖此默认操作,以获得更有意义的确认提示。例如。具有意图的描述而不是其标识符名称。 |
| 15 | ActionDefaultAskRephrase(ActionBotResponse) | "action_default_ask_rephrase" | 默认实现,询问用户重新表达他的意图。 |
| 16 | ActionSendText(Action) | "action_send_text" | 向输出通道发送文本消息。 |
| 17 | ActionExtractSlots(Action) | "action_extract_slots" | 每个用户回合后自动运行的默认操作。在下一个预测的操作运行之前,在 MessageProcessor.handle_message(...)中自动执行操作。根据分配的槽映射将插槽设置为从用户消息中提取的值。 |
| 18 | ACTION_TWO_STAGE_FALLBACK_NAME | "action_two_stage_fallback" | —— |
| 19 | ACTION_VALIDATE_SLOT_MAPPINGS | "action_validate_slot_mappings" | —— |
| 20 | RULE_SNIPPET_ACTION_NAME | "..." | —— |
(7)data:数据
取出一条 data 数据字段进行 json 显示,如下所示:
{
"event": "action",
"timestamp": 1704297163.3703225,
"metadata": {
"model_id": "4ca8c86f1301497f9488c47c860f39fd",
"assistant_id": "20240103-232935-excited-category"
},
"name": "action_session_start",
"policy": null,
"confidence": 1.0,
"action_text": null,
"hide_rule_turn": false
}
event:事件的名字。(列出全部)timestamp:时间戳。metadata-model_id:模型 id。这个并不是模型的名字,比如训练的报时机器人模型为20240103-233232-windy-borzoi.tar.gz。metadata-assistant_id:这个是 config.yml 文件中定义的 assistant_id: 20240103-232935-excited-category。name:action 的名字。 policy:使用的策略。 confidence:置信度。 action_text:动作文本,即端到端机器人响应的文本。 hide_rule_turn:是否隐藏规则回合。
上述字段大都来自于 ACTION_EXECUTED(rasa/shared/utils/schemas/events.py),ACTION_EXECUTED 是一个字典,它定义了 Rasa 中 "action" 事件的 JSON schema。这个 schema 描述了 "action" 事件的数据结构,包括它的属性和这些属性的类型。如下所示:
ACTION_EXECUTED = {
"properties": {
"event": {"const": "action"},
"policy": {"type": ["string", "null"]},
"confidence": {"type": ["number", "null"]},
"name": {"type": ["string", "null"]},
"hide_rule_turn": {"type": "boolean"},
"action_text": {"type": ["string", "null"]},
}
}
在 ACTION_EXECUTED schema 中,相关属性解释如下所示:
event: 这是一个常量,值为 "action",表示这是一个 "action" 事件。policy: 这是一个字符串,表示执行这个动作的策略的名称。它也可以为 null。confidence: 这是一个数字,表示执行这个动作的策略的置信度。它也可以为 null。name: 这是一个字符串,表示执行的动作的名称。它也可以为 null。hide_rule_turn: 这是一个布尔值,表示是否隐藏规则回合。action_text: 这是一个字符串,表示动作的文本。它也可以为 null。
这个 schema 用于验证 "action" 事件的数据是否符合预期的格式。如果一个 "action" 事件的数据不符合这个 schema,那么在处理这个事件时,Rasa 将会抛出一个错误。
5.action 和 event 间的关系
在 Rasa 中,动作(action)和事件(event)是两个不同但相关的概念。如下所示:
(1)action
动作是在对话中执行的一些操作,例如向用户发送消息、调用外部服务、或者进行自定义的计算。在 Rasa 中,动作通常与对话策略相关联,用于决定在特定的对话状态下应该执行哪个动作。动作由自定义的动作类或内置的动作类实现,它们被定义为继承自 Action 类。
(2)event
事件是对话中的状态更改的表示,例如用户的输入、机器人的响应、槽位的更新等。在 Rasa 中,对话的历史记录是一系列事件的集合。事件被用于跟踪对话的状态,对话管理器使用事件来更新对话状态。不同的事件类型表示不同的对话动作和状态变化。
(3)两者关系
当动作执行时,通常会生成一个或多个事件,这些事件描述了对话状态的变化。 每个对话轮次中都会有一系列事件,包括用户的输入事件(例如 UserUttered)、动作执行事件(例如ActionExecuted)、槽位更新事件(例如SlotSet)、机器人响应事件(例如BotUttered)等。
在对话中,动作和事件密切相互关联。动作执行时会触发事件,这些事件进而影响对话状态的演进。一般来说,对话的历史记录中的事件序列描述了对话的全貌,对话管理器利用这些事件来进行决策。
三.(event_broker)endpoints.yml 和 events 表结构
1.endpoints.yml 配置文件
使用 mysql 数据库来消息队列,如下所示:
event_broker:
type: SQL
url: "localhost"
port: 3306
dialect: "mysql+pymysql"
username: "root"
password: "root"
db: "rasa_event_broker"
2.新建数据库 rasa_tracker_store
创建数据库方式与 rasa_tracker_store 相同,这里不再赘述。
3.events 表结构
生成的 rasa_event_broker.events 数据表内容,如下所示:

(1)id:主键 id
(2)sender_id:发送者 id
(3)data:数据
4.events 表数据内容

取出一条 data 数据字段进行 json 显示,如下所示:
{
"sender_id": "oCMNVZ44YCbHIcFYAAAB",
"event": "action",
"timestamp": 1704297163.3703225,
"metadata": {
"model_id": "4ca8c86f1301497f9488c47c860f39fd",
"assistant_id": "20240103-232935-excited-category"
},
"name": "action_session_start",
"policy": null,
"confidence": 1.0,
"action_text": null,
"hide_rule_turn": false
}
发现 event_broker.events.data 和 tracker_store.events.data 相比,除了多一个 sender_id 字段,其它的都是一样的。跟踪源码发现,如下所示:

执行顺序是先发布新的 tracker 事件集合到 event_broker,然后逐一遍历 event,并将其存储到 tracker_store.event 表中。
四.EventBroker 类和 SQLEventBroker(EventBroker)类
1.EventBroker 类

2.SQLEventBroker(EventBroker)类
SQLEventBroker(EventBroker)类有个内部类 SQLBrokerEvent(Base),定义了 events 的 3 个字段,分别为 id、sender_id 和 data。剩下的基本是对 EventBroker 基类中方法的具体实现。rasa/core/brokers/sql.py 如下所示:

五.TrackerStore 类和 SQLTrackerStore 类
1.TrackerStore 类
表示所有 TrackerStore 的公共行为和接口,如下所示:




2.SQLTrackerStore 类
对 TrackerStore 基类的实现,包括 InMemoryTrackerStore、RedisTrackerStore、DynamoTrackerStore、MongoTrackerStore、SQLTrackerStore。SQLTrackerStore(TrackerStore, SerializedTrackerAsText)类有个内部类 SQLEvent(Base),定义了 events 的 7 个字段,分别为 id、sender_id、type_name、timestamp、intent_name、action_name 和 data。剩下的基本是对 TrackerStore 基类中方法的具体实现。rasa/core/tracker_store.py 如下所示:

参考文献
[1] rasa-v2024010701(报时机器人)源码:https://github.com/ai408/nlp-engineering/tree/main/知识工程-对话系统/公众号代码/rasa-v2024010701
[2] 报时机器人的 rasa shell 执行流程分析:https://z0yrmerhgi8.feishu.cn/wiki/CvASwk5SmiYkCXkqONycSxVfnJg
[3] 打通 Rasa Action Server 和 LLM 接口的尝试方法:https://z0yrmerhgi8.feishu.cn/wiki/UQa0wQBeJi6K7oknz2wcaSTnnNb
[4] 以报时机器人为例详细介绍tracker_store和event_broker:https://z0yrmerhgi8.feishu.cn/wiki/SQSGwzYR7iKSNukQDKicz1Vqnvg
以报时机器人为例详细介绍tracker_store和event_broker的更多相关文章
- Android manifest之manifest标签详细介绍
AndroidManifest详细介绍 本文主要对AndroidManifest.xml文件中各个标签进行说明.索引如下: 概要PART--01 manifest标签PART--02 安全机制和per ...
- 【WiFi密码破解详细图文教程】ZOL仅此一份 详细介绍从CDlinux U盘启动到设置扫描破解-破解软件论坛-ZOL中关村在线
body { font-family: Microsoft YaHei UI,"Microsoft YaHei", Georgia,Helvetica,Arial,sans-ser ...
- kvm详细介绍
KVM详解,太详细太深入了,经典 2016-07-18 19:56:38 分类: 虚拟化 原文地址:KVM详解,太详细太深入了,经典 作者:zzjlzx KVM 介绍(1):简介及安装 http:// ...
- RabbitMQ消息队列(一): Detailed Introduction 详细介绍
http://blog.csdn.net/anzhsoft/article/details/19563091 RabbitMQ消息队列(一): Detailed Introduction 详细介绍 ...
- Oracle Merge into 详细介绍
Oracle Merge into 详细介绍 /*Merge into 详细介绍MERGE语句是Oracle9i新增的语法,用来合并UPDATE和INSERT语句.通过MERGE语句,根据一张表或子查 ...
- 【转载】硬盘MBR详细介绍
原文地址:http://blog.chinaunix.net/uid-15007890-id-106892.html 硬盘MBR详细介绍 硬盘是现在计算机上最常用的存储器之一.我们都知道,计 ...
- Java 集合系列10之 HashMap详细介绍(源码解析)和使用示例
概要 这一章,我们对HashMap进行学习.我们先对HashMap有个整体认识,然后再学习它的源码,最后再通过实例来学会使用HashMap.内容包括:第1部分 HashMap介绍第2部分 HashMa ...
- C++中引用与指针的区别(详细介绍)
C++中引用与指针的区别(详细介绍) C++中的引用与指针的区别 指向不同类型的指针的区别在于指针类型可以知道编译器解释某个特定地址(指针指向的地址)中的内存内容及大小,而void*指针则只表示一 ...
- web.xml 详细介绍(转)
web.xml 详细介绍 1.启动一个WEB项目的时候,WEB容器会去读取它的配置文件web.xml,读取<listener>和<context-param>两个结点. 2.紧 ...
- Ubuntu根目录下各文件夹的功能详细介绍
Ubuntu的根目录下存在着很多的文件夹,但你知道他们都存放着哪些文件呢?这些是深入了解Ubuntu系统必不缺少的知识,本文就关于此做一下介绍吧. /bin/ 用以存储二进制可执行命令文件. / ...
随机推荐
- 对某个接口进行限流 以 Aop 注解的形式绑定接口 用redis实现
简单的针对某个接口进行限流,如果需要整体限流的话还是建议在网关上面或者服务器上面动手Controller: @LimitRequest(count = 1,time = 60 * 1000 * 2) ...
- 好用!这些工具国庆一定要研究下「GitHub 热点速览」
再过 3 天就要开始一年最长的假期--国庆长假了,这次除了宅家.出游之外,多了一个新选项:研究下哪些项目可以安排上,来辅助自己的日常开发. 你觉得一周获得 4k star 的 hyperdx 如何,它 ...
- Kubeflow基础知识
kubeflow 基础知识 kubeflow 简介 kubeflow是谷歌开源的MLOps开源平台,其中包含的不同组件代表了机器学习生命周期的不同阶段. 下图是kubeflow组织ML工作流程: ku ...
- C#堆排序算法
前言 堆排序是一种高效的排序算法,基于二叉堆数据结构实现.它具有稳定性.时间复杂度为O(nlogn)和空间复杂度为O(1)的特点. 堆排序实现原理 构建最大堆:将待排序数组构建成一个最大堆,即满足父节 ...
- OI-note
版权声明:仅供学习. 持续更新中...也算是个人学习的监督与激励吧. OI路漫漫,且行且珍惜. OI太颓了,模拟赛都打不动,班级全是大佬. 算法综合 \(Algorithm\) 杂题综合 Index ...
- PTA乙级1039(C++)散列表解法
题目 1039 到底买不买 小红想买些珠子做一串自己喜欢的珠串.卖珠子的摊主有很多串五颜六色的珠串,但是不肯把任何一串拆散了卖. 于是小红要你帮忙判断一下,某串珠子里是否包含了全部自己想要的珠子?如 ...
- CF1610B [Kalindrome Array]
Problem 题目简述 给你一个数列 \(a\),有这两种情况,这个数列是「可爱的」. 它本身就是回文的. 定义变量 \(x\),满足:序列 \(a\) 中所有值等于 \(x\) 的元素删除之后,它 ...
- CF85B [Embassy Queue]
Problem 题目简述 有 \(n\) 个人分别在 \(c_i\) 的时刻来,他们都要在 \(k_1\),\(k_2\) 和 \(k_3\) 窗口干不同的事,当有后面一人也排在在同一窗口时,必须等待 ...
- [Python] Turtle库的运用, 创作精美绘画
更多示例代码下载地址 : https://github.com/Amd794/Python123 前言 最初来自于 Wally Feurzig 和 Seymour Papert 于 1966 年所创造 ...
- Python 利用pandas和matplotlib绘制双柱状图
在数据分析和可视化中,常用的一种图形类型是柱状图.柱状图能够清晰地展示不同分类变量的数值,并支持多组数据进行对比.本篇文章将介绍如何使用Python绘制双柱状图. 准备工作 在开始绘制柱状图之前,需要 ...