手动控制说话开始和结束
与对话式智能体互动时,你可能需要在端侧显式控制用户开始说话和结束说话的时机,以适配 AI 面试、互动答题、对讲机等对回合边界控制要求较高的场景。本文介绍如何在不接入 ConvoAI 客户端组件的情况下,直接通过 RTM 裸消息发送手动 SoS 和手动 EoS 请求,并处理服务端返回的结果。
适合以下场景:
- 你希望自行封装 RTM 收发逻辑,而不依赖客户端组件。
- 你的业务已有独立的 RTM 消息分发层,需要将 ConvoAI 事件统一接入现有链路。
- 你需要在 PTT、AI 面试、互动答题等场景中显式控制用户开始说话和结束说话的时机。
功能介绍
手动轮次控制包含两个独立能力:
- 手动 SoS:客户端显式声明“用户开始说话”。
- 手动 EoS:客户端显式声明“用户结束说话”。
两者可以分别开启:
- 只将
end_of_speech.mode设为manual:适合“开始说话仍由 VAD 或语义检测判断,结束说话由用户主动提交”的场景。 - 同时将
start_of_speech.mode和end_of_speech.mode设为manual:适合完整的 PTT 或对讲机模式。
前提条件
开始前,请确保你已经:
- 参考实现对话式智能体完成与智能体对话的基本流程。
- 完成 RTM SDK 的基础集成,并具备基础的 RTM 登录、订阅和消息监听能力。可参考实现收发消息。
- 创建智能体时需要开启
advanced_features.enable_rtm = true。
实现手动控制说话开始和结束
创建智能体时开启手动轮次控制
调用 POST 创建对话式智能体 时,先开启 RTM,再根据你的业务场景决定是手动控制 SoS、手动控制 EoS,还是两者都手动控制。
下面给出两种常见配置方式的 curl 请求示例:
- 仅手动 EoS
- 手动 SoS + EoS
curl --request POST \
--url https://api.agora.io/cn/api/conversational-ai-agent/v2/projects/<your_app_id>/join \
--header 'Authorization: agora token="007abcxxxxxxx123"' \
--data '
{
"name": "manual-eos-agent",
"properties": {
"channel": "channel_name",
"token": "token",
"agent_rtc_uid": "0",
"remote_rtc_uids": [
"123"
],
"advanced_features": {
"enable_rtm": true
},
"asr": {
"language": "zh-CN"
},
"llm": {
"url": "https://api.xxxx/v1/xxxx",
"api_key": "xxx",
"system_messages": [
{
"role": "system",
"content": "You are a helpful chatbot."
}
],
"greeting_message": "您好,有什么可以帮您?",
"failure_message": "抱歉,我无法回答这个问题。",
"max_history": 10,
"params": {
"model": "xxxx"
}
},
"tts": {
"vendor": "minimax",
"params": {
"key": "your-minimax-key",
"model": "speech-01-turbo",
"voice_setting": {
"voice_id": "female-shaonv",
"speed": 1,
"vol": 1,
"pitch": 0,
"emotion": "happy"
},
"audio_setting": {
"sample_rate": 16000
}
}
},
"turn_detection": {
"mode": "default",
"config": {
"start_of_speech": {
"mode": "vad"
},
"end_of_speech": {
"mode": "manual"
}
}
}
}
}
'
curl --request POST \
--url https://api.agora.io/cn/api/conversational-ai-agent/v2/projects/<your_app_id>/join \
--header 'Authorization: agora token="007abcxxxxxxx123"' \
--data '
{
"name": "manual-sos-eos-agent",
"properties": {
"channel": "channel_name",
"token": "token",
"agent_rtc_uid": "0",
"remote_rtc_uids": [
"123"
],
"advanced_features": {
"enable_rtm": true
},
"asr": {
"language": "zh-CN"
},
"llm": {
"url": "https://api.xxxx/v1/xxxx",
"api_key": "xxx",
"system_messages": [
{
"role": "system",
"content": "You are a helpful chatbot."
}
],
"greeting_message": "您好,有什么可以帮您?",
"failure_message": "抱歉,我无法回答这个问题。",
"max_history": 10,
"params": {
"model": "xxxx"
}
},
"tts": {
"vendor": "minimax",
"params": {
"key": "your-minimax-key",
"model": "speech-01-turbo",
"voice_setting": {
"voice_id": "female-shaonv",
"speed": 1,
"vol": 1,
"pitch": 0,
"emotion": "happy"
},
"audio_setting": {
"sample_rate": 16000
}
}
},
"turn_detection": {
"mode": "default",
"config": {
"start_of_speech": {
"mode": "manual"
},
"end_of_speech": {
"mode": "manual"
}
}
}
}
}
'
完成这一步后,请记录以下信息,后续步骤会用到:
channelName:本次会话所在频道名。userId:当前用户的 RTM 登录 ID。agentUserId:智能体的 RTM 用户 ID,通常与agent_rtc_uid对应。
登录 RTM 并订阅频道消息
在发送手动 SoS/EoS 之前,先让客户端完成 RTM 登录,并订阅 channelName。这样你才能及时收到智能体返回的处理结果。
val rtmConfig = RtmConfig.Builder(appId, userId.toString()).build()
val rtmClient = RtmClient.create(rtmConfig)
rtmClient.addEventListener(object : RtmEventListener {
override fun onMessageEvent(event: MessageEvent?) {
event ?: return
val message = event.message ?: return
val rawJson = when (message.type) {
RtmConstants.RtmMessageType.BINARY -> {
val bytes = message.data as? ByteArray ?: return
String(bytes, Charsets.UTF_8)
}
else -> message.data as? String ?: return
}
handleAgentRtmMessage(
publisherId = event.publisherId.orEmpty(),
channelName = event.channelName.orEmpty(),
customType = event.customType.orEmpty(),
rawJson = rawJson
)
}
})
rtmClient.login(rtmToken, object : ResultCallback<Void> {
override fun onSuccess(responseInfo: Void?) {
val options = SubscribeOptions().apply {
withMessage = true
withPresence = true
}
rtmClient.subscribe(channelName, options, object : ResultCallback<Void> {
override fun onSuccess(responseInfo: Void?) {
// RTM 订阅成功。
}
override fun onFailure(errorInfo: ErrorInfo) {
// 处理订阅失败。
}
})
}
override fun onFailure(errorInfo: ErrorInfo) {
// 处理 RTM 登录失败。
}
})
建议先完成 RTM 登录和频道订阅,再启动智能体,避免错过智能体启动早期的回调消息。
在用户开始说话时发送手动 SoS
当你的业务逻辑确认用户开始说话时,向 agentUserId 发送一条 RTM USER 点对点消息。发送时使用 RtmConstants.RtmChannelType.USER,并将 customType 设置为 user.manual_sos。
fun publishManualSos(
rtmClient: RtmClient,
agentUserId: String,
completion: (String, ErrorInfo?) -> Unit
) {
val requestId = "sos-req-${System.currentTimeMillis()}-${UUID.randomUUID()}"
val body = JSONObject(mapOf("request_id" to requestId)).toString()
val options = PublishOptions().apply {
setChannelType(RtmConstants.RtmChannelType.USER)
setCustomType("user.manual_sos")
}
rtmClient.publish(agentUserId, body, options, object : ResultCallback<Void> {
override fun onSuccess(responseInfo: Void?) {
completion(requestId, null)
}
override fun onFailure(errorInfo: ErrorInfo) {
completion(requestId, errorInfo)
}
})
}
这条消息可以表示为:
{
"customType": "user.manual_sos",
"publisher": "123",
"message": "{\"request_id\":\"sos-req-20260612-001\"}"
}
- RTM
publish成功,只表示消息已发出。 - 智能体是否真正接受本次 SoS,需要等待
user.manual_sos.result回调确认。
在用户结束说话时发送手动 EoS
当你的业务逻辑确认用户说完后,向 agentUserId 发送一条 RTM USER 点对点消息。发送时使用 RtmConstants.RtmChannelType.USER,并将 customType 设置为 user.manual_eos。
fun publishManualEos(
rtmClient: RtmClient,
agentUserId: String,
completion: (String, ErrorInfo?) -> Unit
) {
val requestId = "eos-req-${System.currentTimeMillis()}-${UUID.randomUUID()}"
val body = JSONObject(mapOf("request_id" to requestId)).toString()
val options = PublishOptions().apply {
setChannelType(RtmConstants.RtmChannelType.USER)
setCustomType("user.manual_eos")
}
rtmClient.publish(agentUserId, body, options, object : ResultCallback<Void> {
override fun onSuccess(responseInfo: Void?) {
completion(requestId, null)
}
override fun onFailure(errorInfo: ErrorInfo) {
completion(requestId, errorInfo)
}
})
}
这条消息可以表示为:
{
"customType": "user.manual_eos",
"publisher": "123",
"message": "{\"request_id\":\"eos-req-20260612-001\"}"
}
- RTM
publish成功,只表示消息已发出。 - 智能体是否真正接受本次 EoS,需要等待
user.manual_eos.result回调确认。
监听并处理回调消息
手动 SoS/EoS 的处理结果通过 RTM 频道消息返回。收到消息后,先解析消息体 JSON,再根据内层 message 中的 event_type 区分事件类型。
区分回调消息类型
常见回调类型包括:
user.manual_sos.result:服务端返回客户端手动 SoS 请求的处理结果。user.manual_eos.result:服务端返回客户端手动 EoS 请求的处理结果。assistant.manual_eos.result:手动模式下,服务端因最长说话时长等保护策略自动触发 EoS。
- user.manual_sos.result
- user.manual_eos.result
- assistant.manual_eos.result
{
"event_type": "user.manual_sos.result",
"event_id": "evt_xxx",
"event_ms": 1773901235435,
"payload": {
"success": true,
"request_id": "sos-req-1773901235000-a1b2c3d4",
"turn_id": 12
}
}
字段说明如下:
event_type:事件类型,固定为user.manual_sos.result。event_id:事件唯一 ID。event_ms:事件发送时间戳,单位为毫秒。payload.success:本次手动 SoS 请求是否处理成功。payload.request_id:与你发送手动 SoS 时传入的request_id对应。payload.turn_id:本次请求关联的对话轮次 ID。
{
"event_type": "user.manual_eos.result",
"event_id": "evt_xxx",
"event_ms": 1773901240000,
"payload": {
"success": true,
"request_id": "eos-req-1773901240000-b2c3d4e5",
"turn_id": 12
}
}
字段说明如下:
event_type:事件类型,固定为user.manual_eos.result。event_id:事件唯一 ID。event_ms:事件发送时间戳,单位为毫秒。payload.success:本次手动 EoS 请求是否处理成功。payload.request_id:与你发送手动 EoS 时传入的request_id对应。payload.turn_id:本次请求关联的对话轮次 ID。
{
"event_type": "assistant.manual_eos.result",
"event_id": "evt_xxx",
"event_ms": 1773901250000,
"payload": {
"reason": "max_duration_reached",
"max_duration_ms": 60000,
"turn_id": 12
}
}
字段说明如下:
event_type:事件类型,固定为assistant.manual_eos.result。event_id:事件唯一 ID。event_ms:事件发送时间戳,单位为毫秒。payload.reason:服务端自动触发 EoS 的原因。payload.max_duration_ms:服务端允许的最长说话时长上限,单位为毫秒。payload.turn_id:被服务端自动结束的对话轮次 ID。
根据 request_id 处理结果回调
在 RTM 消息回调中,建议你统一解析 event_type、request_id、success、turn_id 等字段,并根据结果更新业务状态。
data class ManualResult(
val type: String,
val requestId: String,
val success: Boolean,
val turnId: Long?,
val errorMessage: String?
)
fun handleAgentRtmMessage(
publisherId: String,
channelName: String,
customType: String,
rawJson: String
) {
val json = JSONObject(rawJson)
val type = json.optString("event_type").ifEmpty {
json.optString("object")
}
val payload = json.optJSONObject("payload")
when (type) {
"user.manual_sos.result",
"user.manual_eos.result" -> {
if (payload == null) return
val result = ManualResult(
type = type,
requestId = payload.optString("request_id"),
success = payload.optBoolean("success", false),
turnId = payload.optLongOrNull("turn_id"),
errorMessage = payload.optString("error_message").ifEmpty { null }
)
onUserManualResult(publisherId, result)
}
"assistant.manual_eos.result" -> {
if (payload == null) return
val reason = payload.optString("reason")
val maxDurationMs = payload.optLong("max_duration_ms")
val turnId = payload.optLong("turn_id")
onAgentManualEos(
agentUserId = publisherId,
reason = reason,
maxDurationMs = maxDurationMs,
turnId = turnId
)
}
}
}
fun JSONObject.optLongOrNull(name: String): Long? {
return if (has(name) && !isNull(name)) optLong(name) else null
}
在这一步中,需要特别注意:
- 使用
request_id关联你之前发送的 SoS/EoS 请求。 - 使用
publisherId识别智能体 RTM 用户 ID。 - 以消息体中的
event_type为准区分事件类型,不要依赖外层 RTM 消息的customType。