# 手动控制说话开始和结束 (/zh-CN/ai/build/shape-the-conversation/manual-turn-control)

> For AI agents: see the complete documentation index at [llms.txt](/llms.txt).

当你的业务场景不适合依赖服务端自动判定用户何时开始说话或结束说话时，可以使用手动 SoS/EoS 能力，由客户端显式声明用户回合边界。该能力适用于 AI 面试、互动答题、对讲机模式等需要“按下开始、点击提交”的交互。本文介绍如何通过创建智能体配置和客户端组件接口，实现手动控制用户说话边界。

## 集成流程 [#集成流程]

手动轮次控制需要同时配置智能体启动参数和客户端组件：

```text
启动 Agent 时配置 manual 模式
→ 客户端初始化 toolkit 并订阅 RTM 消息
→ 注册手动 SoS/EoS 结果回调
→ 用户点击开始/提交时调用 manualSOS/manualEOS
```

## 选择控制模式 [#选择控制模式]

`start_of_speech.mode` 和 `end_of_speech.mode` 是两个独立配置。先根据交互选择需要手动控制的边界：

| 业务场景 | `start_of_speech.mode` | `end_of_speech.mode` | 客户端调用 |
|---|---|---|---|
| 用户可自由开始说话，但需要点击“提交答案” | `vad` | `manual` | 只调用 `manualEOS` |
| 用户点击或按下后开始说话，结束由服务端自动判停 | `manual` | `vad` | 只调用 `manualSOS` |
| 按住说话、松开发送、对讲机模式 | `manual` | `manual` | 先调用 `manualSOS`，再调用 `manualEOS` |

如果只把 `end_of_speech.mode` 设为 `manual`，则服务端仍会通过 VAD 自动识别用户开始说话；业务只需要在用户点击提交时调用 `manualEOS`。

如果只把 `start_of_speech.mode` 设为 `manual`，则服务端只会在客户端调用 `manualSOS` 后开始接收本轮用户音频；结束说话仍由 VAD 自动识别，业务不需要调用 `manualEOS`。

如果同时把 `start_of_speech.mode` 和 `end_of_speech.mode` 设为 `manual`，则服务端只会在客户端调用 `manualSOS` 后开始接收本轮用户音频，并在客户端调用 `manualEOS` 后提交本轮发言。

## 前提条件 [#前提条件]

开始前，请确保完成以下准备工作：

- 已集成 RTC SDK v4.5.1 及以上版本，且已在 App 中实现基本的实时音视频功能并获取相关设备权限。请参考[实现音视频互动](/zh-CN/realtime-media/rtc/get-started/quick-start)。
- 已在[控制台](https://console.shengwang.cn/)为项目启用 RTM 服务，并在 App 中实现基本的实时消息功能。请参考[实现收发消息](/zh-CN/realtime-media/rtm/get-started/quick-start)。
- 已参考[实现对话式智能体](/zh-CN/ai/get-started/quick-start)实现与智能体对话的基本逻辑。
- 确保 RTC 可用、RTM 已登录，且 RTC 和 RTM 实例的生命周期大于组件的生命周期。组件内部不负责维护 RTC，RTM 的初始化、生命周期以及鉴权/登录状态的逻辑。

## 实现手动控制用户说话开始和结束 [#实现手动控制用户说话开始和结束]

### 创建智能体时开启手动轮次控制 [#创建智能体时开启手动轮次控制]

调用 [POST 创建对话式智能体](/zh-CN/api-reference/api-ref/conversational-ai/join) 接口时，开启 RTM，并根据业务场景将需要手动控制的一侧设为 `manual`。

<Tabs defaultValue="manual-eos" groupId="manual-turn-control-mode" persist>
<TabsList>
  <TabsTrigger value="manual-eos">仅手动控制 EoS</TabsTrigger>
  <TabsTrigger value="manual-sos">仅手动控制 SoS</TabsTrigger>
  <TabsTrigger value="manual-sos-eos">手动控制 SoS 和 EoS</TabsTrigger>
</TabsList>

<TabsContent value="manual-eos">

如果你只需要手动控制结束说话，可以保留自动 SoS，仅将 `end_of_speech.mode` 设为 `manual`。

```json
{
  "name": "manual-eos-agent",
  "properties": {
    "channel": "channel_name",
    "token": "token",
    "agent_rtc_uid": "0",
    "remote_rtc_uids": ["123"],
    "advanced_features": {
      "enable_rtm": true
    },
    "turn_detection": {
      "mode": "default",
      "config": {
        "start_of_speech": {
          "mode": "vad",
          "vad_config": {
            "interrupt_duration_ms": 160,
            "speaking_interrupt_duration_ms": 320,
            "prefix_padding_ms": 800
          }
        },
        // highlight-start
        "end_of_speech": {
          "mode": "manual"
        }
      }
    },
    "parameters": {
      "data_channel": "rtm"
    }
    //highlight-end
  }
}
```

</TabsContent>

<TabsContent value="manual-sos">

如果你只需要手动控制开始说话，可以将 `start_of_speech.mode` 设为 `manual`，并保留自动 EoS。

```json
{
  "name": "manual-sos-agent",
  "properties": {
    "channel": "channel_name",
    "token": "token",
    "agent_rtc_uid": "0",
    "remote_rtc_uids": ["123"],
    "advanced_features": {
      "enable_rtm": true
    },
    "turn_detection": {
      "mode": "default",
      "config": {
        //highlight-start
        "start_of_speech": {
          "mode": "manual"
        },
        "end_of_speech": {
          "mode": "vad",
          "vad_config": {
            "silence_duration_ms": 480
          }
        }
      }
    },
    "parameters": {
      "data_channel": "rtm"
    }
    //highlight-end
  }
}
```

</TabsContent>

<TabsContent value="manual-sos-eos">

如果你需要完整的对讲机模式，则同时将 `start_of_speech.mode` 和 `end_of_speech.mode` 设为 `manual`。

```json
{
  "name": "manual-sos-eos-agent",
  "properties": {
    "channel": "channel_name",
    "token": "token",
    "agent_rtc_uid": "0",
    "remote_rtc_uids": ["123"],
    "advanced_features": {
      "enable_rtm": true
    },
    "turn_detection": {
      "mode": "default",
      "config": {
        //highlight-start
        "start_of_speech": {
          "mode": "manual"
        },
        "end_of_speech": {
          "mode": "manual"
        }
      }
    },
    "parameters": {
      "data_channel": "rtm"
    }
    //highlight-end
  }
}
```

</TabsContent>
</Tabs>

### 集成并初始化客户端组件 [#集成并初始化客户端组件]

先完成组件集成：

<Tabs defaultValue="android" groupId="language" persist>
<TabsList>
  <TabsTrigger value="android">Android</TabsTrigger>
  <TabsTrigger value="ios">iOS</TabsTrigger>
  <TabsTrigger value="web">Web</TabsTrigger>
</TabsList>

<TabsContent value="android">

<Tabs defaultValue="maven" groupId="convoai-android-install-method" persist>
<TabsList aria-label="Android 组件集成方式">
  <TabsTrigger value="maven">Maven</TabsTrigger>
  <TabsTrigger value="source">源码集成</TabsTrigger>
</TabsList>

<TabsContent value="maven">

#### Maven [#maven]

在 Android 项目的 `build.gradle` 文件中添加如下依赖：

```groovy
implementation 'io.agora.agents:agora-agent-client-toolkit:2.10.0'
```

</TabsContent>

<TabsContent value="source">

#### 源码集成 [#源码集成]

将 `convoaiApi` 文件夹拷贝到你的项目中，并在后续调用组件 API 前引入组件。

<Cards>
  <Card title="agent-client-toolkit-kotlin" href="https://github.com/AgoraIO-Conversational-AI/agent-client-toolkit-kotlin/tree/main" />
</Cards>

</TabsContent>

</Tabs>

</TabsContent>

<TabsContent value="ios">

<Tabs defaultValue="cocoapods" groupId="convoai-ios-install-method" persist>
<TabsList aria-label="iOS 组件集成方式">
  <TabsTrigger value="cocoapods">CocoaPods</TabsTrigger>
  <TabsTrigger value="swiftpm">Swift Package Manager</TabsTrigger>
  <TabsTrigger value="source">源码集成</TabsTrigger>
</TabsList>

<TabsContent value="cocoapods">

#### CocoaPods [#cocoapods]

在 `Podfile` 中添加如下依赖：

```ruby
pod 'agent-client-toolkit-swift', '2.10.0'
```

</TabsContent>

<TabsContent value="swiftpm">

#### Swift Package Manager [#swift-package-manager]

在 Xcode 中添加如下 Package URL，并将版本指定为 tag `2.10.0`：

```text
https://github.com/AgoraIO/agent-client-toolkit-swift.git
```

</TabsContent>

<TabsContent value="source">

#### 源码集成 [#源码集成-1]

将 `ConversationalAIAPI` 文件夹拷贝到你的项目中，并在后续调用组件 API 前引入组件。

<Cards>
  <Card title="agent-client-toolkit-swift" href="https://github.com/AgoraIO/agent-client-toolkit-swift/tree/source/main" />
</Cards>

</TabsContent>

</Tabs>

</TabsContent>

<TabsContent value="web">

<Tabs defaultValue="package" groupId="convoai-web-install-method" persist>
<TabsList aria-label="Web 组件集成方式">
  <TabsTrigger value="package">Package Manager</TabsTrigger>
  <TabsTrigger value="source">源码集成</TabsTrigger>
</TabsList>

<TabsContent value="package">

#### Package Manager [#package-manager]

根据你的项目类型安装组件依赖。

- Vanilla JS / TypeScript：

  ```shell
  pnpm add agora-agent-client-toolkit@2.10.0
  ```

- React：

  ```shell
  pnpm add agora-agent-client-toolkit@2.10.0 agora-agent-client-toolkit-react@2.10.0
  ```

</TabsContent>

<TabsContent value="source">

#### 源码集成 [#源码集成-2]

将 `conversational-ai-api` 文件夹拷贝到你的项目中，并在后续调用组件 API 前引入组件。

<Cards>
  <Card title="agent-client-toolkit-ts" href="https://github.com/AgoraIO-Conversational-AI/agent-client-toolkit-ts/tree/main" />
</Cards>

</TabsContent>

</Tabs>

</TabsContent>
</Tabs>

:::info[信息]
如果在 Android 或 iOS 项目中同时集成多个声网 SDK 时出现库冲突，请参考[多 SDK 库冲突处理](/zh-CN/reference/faq/integration/rtm2_rtc_integration_issue)。
:::

然后使用已有的 RTC 和 RTM 实例初始化客户端组件。

```kotlin tab="Android" tabGroup="language"
val config = ConversationalAIAPIConfig(
    rtcEngine = rtcEngine,
    rtmClient = rtmClient,
    renderMode = TranscriptRenderMode.Word,
    enableLog = true,
    enableRenderModeFallback = true
)

val api = ConversationalAIAPIImpl(config)
```

```swift tab="iOS" tabGroup="language"
let config = ConversationalAIAPIConfig(
    rtcEngine: rtcEngine,
    rtmEngine: rtmEngine,
    renderMode: .words,
    enableLog: true,
    enableRenderModeFallback: true
)

convoAIAPI = ConversationalAIAPIImpl(config: config)
```

```typescript tab="Web" tabGroup="language"
const config: IConversationalAIAPIConfig = {
  rtcEngine: rtcEngine,
  rtmEngine: rtmEngine,
  renderMode: ETranscriptHelperMode.WORD,
  enableLog: true,
  enableRenderModeFallback: true,
};

const conversationalAIAPI = await ConversationalAIAPI.init(config);
```

### 注册消息回调并订阅频道消息 [#注册消息回调并订阅频道消息]

手动 SoS/EoS 的处理结果通过专用回调或事件返回，你需要先注册这些回调或事件，再订阅智能体所在频道的消息：

```kotlin tab="Android" tabGroup="language"
api.addHandler(object : IConversationalAIAPIEventHandler {
    override fun onUserManualSosEvent(agentUserId: String, event: UserManualSosEvent) {
        // 处理手动 SOS 结果。event.payload.success 表示服务端是否接受。
    }

    override fun onUserManualEosEvent(agentUserId: String, event: UserManualEosEvent) {
        // 处理手动 EOS 结果。
    }

    override fun onAgentManualEosEvent(agentUserId: String, event: AgentManualEosEvent) {
        // 处理服务端因超限等原因触发的自动 EOS 通知。
    }
})

api.subscribeMessage("channelName") { error ->
    if (error != null) {
        // 处理订阅失败
    }
}
```

```swift tab="iOS" tabGroup="language"
convoAIAPI.addHandler(handler: self)

func onUserManualSosEvent(agentUserId: String, event: UserManualSosEvent) {
    // 处理手动 SOS 结果。event.payload.success 表示服务端是否接受。
}

func onUserManualEosEvent(agentUserId: String, event: UserManualEosEvent) {
    // 处理手动 EOS 结果。
}

func onAgentManualEosEvent(agentUserId: String, event: AgentManualEosEvent) {
    // 处理服务端因超限等原因触发的自动 EOS 通知。
}

convoAIAPI.subscribeMessage(channelName: channelName) { error in
    if let error = error {
        print("订阅失败: \(error.message)")
    }
}
```

```typescript tab="Web" tabGroup="language"
import {
  AgoraVoiceAIEvents,
  type UserManualSosEvent,
  type UserManualEosEvent,
  type AgentManualEosEvent,
} from 'agora-agent-client-toolkit'

conversationalAIAPI.on(
  AgoraVoiceAIEvents.USER_MANUAL_SOS,
  (agentUserId: string, event: UserManualSosEvent) => {
    // 处理手动 SOS 结果。event.payload.success 表示服务端是否接受。
  }
)

conversationalAIAPI.on(
  AgoraVoiceAIEvents.USER_MANUAL_EOS,
  (agentUserId: string, event: UserManualEosEvent) => {
    // 处理手动 EOS 结果。
  }
)

conversationalAIAPI.on(
  AgoraVoiceAIEvents.AGENT_MANUAL_EOS,
  (agentUserId: string, event: AgentManualEosEvent) => {
    // 处理服务端因超限等原因触发的自动 EOS 通知。
  }
)

conversationalAIAPI.subscribeMessage(channelName)
```

:::info[信息]
这些回调用于接收服务端对手动 SoS/EoS 请求的处理结果。你可以根据回调中的 `event`、`requestId` 和错误信息实现自己的业务逻辑。如需查看相关接口和事件说明，请参考[客户端组件 API](#客户端组件-api)。
:::

### 让智能体加入频道 [#让智能体加入频道]

创建智能体后，让智能体加入与你的用户相同的 RTC 频道：

调用 [POST 创建对话式智能体](/zh-CN/api-reference/api-ref/conversational-ai/join)接口，并完成以下参数设置：

- `advanced_features.enable_rtm: true` —— （必选）启动 RTM 服务
- `parameters.data_channel: "rtm"` —— （必选）开启 RTM 数据传输通道
- `parameters.enable_metrics: true` —— （按需开启）接收智能体性能数据
- `parameters.enable_error_message: true` —— （按需开启）接收智能体错误事件

调用成功后，智能体会加入指定 RTC 频道，用户可以开始与智能体互动。

### 在用户开始说话时发送手动 SoS [#在用户开始说话时发送手动-sos]

仅当 `start_of_speech.mode = "manual"` 时需要调用 `manualSOS`。如果你使用 VAD 自动 SoS，可以跳过这一步。

```kotlin tab="Android" tabGroup="language"
api.manualSOS(agentUserId = "agentUserId") { requestId, error ->
    if (error != null) {
        Log.e("ManualSOS", "发送失败: ${error.errorMessage}, requestId=$requestId")
    } else {
        Log.i("ManualSOS", "已发送 SOS, requestId=$requestId")
    }
}
```

```swift tab="iOS" tabGroup="language"
convoAIAPI.manualSOS(agentUserId: agentUid) { requestId, error in
    if let error = error {
        print("发送失败: \(error.message), requestId=\(requestId)")
    } else {
        print("已发送 SOS, requestId=\(requestId)")
    }
}
```

```typescript tab="Web" tabGroup="language"
const requestId = await conversationalAIAPI.manualSOS(
  agentUserId,
  'sos-req-20260612-001'
)
```

手动 SoS 生效后，服务端开始把后续音频计入本轮用户发言。在此之前到达的音频不会计入本轮。

### 在用户结束说话时发送手动 EoS [#在用户结束说话时发送手动-eos]

仅当 `end_of_speech.mode = "manual"` 时需要调用 `manualEOS`。如果你使用 VAD 自动 EoS，可以跳过这一步。

```kotlin tab="Android" tabGroup="language"
api.manualEOS(agentUserId = "agentUserId") { requestId, error ->
    if (error != null) {
        Log.e("ManualEOS", "发送失败: ${error.errorMessage}, requestId=$requestId")
    } else {
        Log.i("ManualEOS", "已发送 EOS, requestId=$requestId")
    }
}
```

```swift tab="iOS" tabGroup="language"
convoAIAPI.manualEOS(agentUserId: agentUid) { requestId, error in
    if let error = error {
        print("发送失败: \(error.message), requestId=\(requestId)")
    } else {
        print("已发送 EOS, requestId=\(requestId)")
    }
}
```

```typescript tab="Web" tabGroup="language"
const requestId = await conversationalAIAPI.manualEOS(
  agentUserId,
  'eos-req-20260612-001'
)
```

手动 EoS 生效后，服务端会按现有链路提交 ASR、触发 LLM 推理，并在后续生成回复。该信号只表示“用户当前发言段结束”，不等同于整个 turn 立即结束。

### 销毁组件实例 [#销毁组件实例]

结束 AI 对话场景后或关闭 App 前，你需要销毁组件实例，以释放组件的所有资源。

```kotlin tab="Android" tabGroup="language"
api.destroy()
```

```swift tab="iOS" tabGroup="language"
convoAIAPI.destroy()
```

```typescript tab="Web" tabGroup="language"
conversationalAIAPI.destroy()
```

## API 参考 [#api-参考]

### 客户端组件 API [#客户端组件-api]

<Tabs defaultValue="android" groupId="language" persist>
<TabsList>
  <TabsTrigger value="android">Android</TabsTrigger>
  <TabsTrigger value="ios">iOS</TabsTrigger>
  <TabsTrigger value="web">Web</TabsTrigger>
</TabsList>

<TabsContent value="android">

- [`addHandler`](/zh-CN/api-reference/conversational-ai/android/iconversationalaiapi#addhandler)
- [`subscribeMessage`](/zh-CN/api-reference/conversational-ai/android/iconversationalaiapi#subscribemessage)
- [`manualSOS`](/zh-CN/api-reference/conversational-ai/android/iconversationalaiapi#manualsos)
- [`manualEOS`](/zh-CN/api-reference/conversational-ai/android/iconversationalaiapi#manualeos)
- [`destroy`](/zh-CN/api-reference/conversational-ai/android/iconversationalaiapi#destroy)
- [`onUserManualSosEvent`](/zh-CN/api-reference/conversational-ai/android/iconversationalaiapieventhandler#onusermanualsosevent)
- [`onUserManualEosEvent`](/zh-CN/api-reference/conversational-ai/android/iconversationalaiapieventhandler#onusermanualeosevent)
- [`onAgentManualEosEvent`](/zh-CN/api-reference/conversational-ai/android/iconversationalaiapieventhandler#onagentmanualeosevent)

</TabsContent>

<TabsContent value="ios">

- [`addHandler`](/zh-CN/api-reference/conversational-ai/ios/conversationalaiapi#addhandler)
- [`subscribeMessage`](/zh-CN/api-reference/conversational-ai/ios/conversationalaiapi#subscribemessage)
- [`manualSOS`](/zh-CN/api-reference/conversational-ai/ios/conversationalaiapi#manualsos)
- [`manualEOS`](/zh-CN/api-reference/conversational-ai/ios/conversationalaiapi#manualeos)
- [`destroy`](/zh-CN/api-reference/conversational-ai/ios/conversationalaiapi#destroy)
- [`onUserManualSosEvent`](/zh-CN/api-reference/conversational-ai/ios/conversationalaiapieventhandler#onusermanualsosevent)
- [`onUserManualEosEvent`](/zh-CN/api-reference/conversational-ai/ios/conversationalaiapieventhandler#onusermanualeosevent)
- [`onAgentManualEosEvent`](/zh-CN/api-reference/conversational-ai/ios/conversationalaiapieventhandler#onagentmanualeosevent)

</TabsContent>

<TabsContent value="web">

- [`subscribeMessage`](/zh-CN/api-reference/conversational-ai/web/conversationalaiapi#subscribemessage)
- [`manualSOS`](/zh-CN/api-reference/conversational-ai/web/conversationalaiapi#manualsos)
- [`manualEOS`](/zh-CN/api-reference/conversational-ai/web/conversationalaiapi#manualeos)
- [`destroy`](/zh-CN/api-reference/conversational-ai/web/conversationalaiapi#destroy)
- [`EConversationalAIAPIEvents`](/zh-CN/api-reference/conversational-ai/web/enum#econversationalaiapievents)

</TabsContent>
</Tabs>
