ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,582 @@
|
||||
# Kimi Web Search Agent / Kimi 网络搜索 Agent
|
||||
|
||||
> Autonomous ReAct web-search agent on Kimi K3 using Moonshot's official Formula API (multi-round search + synthesis).
|
||||
> 配套《深入理解 AI Agent》第 1 章 **实验 1-2 ★:Kimi K3 原生 Agent 能力**。
|
||||
|
||||
← [Chapter 1 index / 返回第 1 章目录](../README.md) · 📖 [Read the chapter / 读本章正文](../../book/chapter1.md)([EN](../../book-en/chapter1.md))
|
||||
|
||||
---
|
||||
|
||||
## English
|
||||
|
||||
### Overview
|
||||
|
||||
This project implements an autonomous AI agent that uses Kimi K3 and Moonshot's
|
||||
official `moonshot/web-search:latest` Formula to:
|
||||
|
||||
- **Understand the question**: analyze the user query and identify information needs
|
||||
- **Search automatically**: fetch live web information through the standard `web_search` function declaration and Formula Fibers
|
||||
- **Iterate**: call search multiple times until evidence is sufficient
|
||||
- **Synthesize**: combine multi-source results into a clear, accurate answer
|
||||
|
||||
It demonstrates the “Model as Agent” idea and the ReAct loop (think → act → observe).
|
||||
|
||||
### Exact Formula route
|
||||
|
||||
Kimi K3's current official hosted-search route is not the legacy
|
||||
`builtin_function` passthrough. Every independent question performs this exact
|
||||
provider-controlled sequence:
|
||||
|
||||
1. `GET /v1/formulas/moonshot/web-search:latest/tools` obtains Moonshot's
|
||||
authoritative standard `function` declaration named `web_search`.
|
||||
2. The declaration is sent unchanged to `POST /v1/chat/completions` with the
|
||||
conversation. Kimi decides whether and how often to call it.
|
||||
3. For each model tool call, the implementation passes the returned `name` and
|
||||
raw serialized `arguments` unchanged to
|
||||
`POST /v1/formulas/moonshot/web-search:latest/fibers`.
|
||||
4. Only HTTP-successful Fibers with `status == "succeeded"` are accepted. Their
|
||||
`context.output` (or encrypted output) is returned as the matching tool result.
|
||||
|
||||
The search engine remains hosted by Moonshot; this repository does not replace
|
||||
it with a local or third-party search implementation. See the official
|
||||
[Formula tool guide](https://platform.kimi.ai/docs/guide/use-official-tools)
|
||||
and [web-search guide](https://platform.kimi.ai/docs/guide/use-web-search).
|
||||
|
||||
### Architecture
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[User question] --> B{Agent thinks}
|
||||
B -->|needs search| C[Model calls web_search]
|
||||
C --> D[POST Formula Fiber]
|
||||
D --> E[Return Fiber output]
|
||||
E --> F{Enough info?}
|
||||
F -->|no| G[Call web_search again]
|
||||
G --> H[More information]
|
||||
H --> F
|
||||
F -->|yes| I[Final answer]
|
||||
B -->|no search needed| J[Answer directly]
|
||||
```
|
||||
|
||||
### Quick Start
|
||||
|
||||
#### 1. Install dependencies
|
||||
|
||||
```bash
|
||||
# Recommended from the repository root: use the shared Chapter 1 environment
|
||||
uv sync --locked --extra ch1
|
||||
|
||||
# Activate it before changing directories:
|
||||
# macOS/Linux:
|
||||
source .venv/bin/activate
|
||||
# Windows PowerShell: .venv\Scripts\Activate.ps1
|
||||
# Windows cmd: .venv\Scripts\activate.bat
|
||||
|
||||
# pip fallback when uv is not installed:
|
||||
# python -m pip install -e ".[ch1]"
|
||||
|
||||
# Enter this experiment directory for the commands below
|
||||
cd chapter1/web-search-agent
|
||||
|
||||
# Single-project compatibility path, still supported during migration:
|
||||
# python -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
#### 2. Configure API Key
|
||||
|
||||
Get a key from the [Moonshot AI platform](https://platform.moonshot.ai/), then set:
|
||||
|
||||
```bash
|
||||
export MOONSHOT_API_KEY='your-api-key-here'
|
||||
```
|
||||
|
||||
Or create a `.env` file:
|
||||
|
||||
```env
|
||||
MOONSHOT_API_KEY=your-api-key-here
|
||||
```
|
||||
|
||||
**Note**: For backward compatibility, `KIMI_API_KEY` is also accepted.
|
||||
|
||||
**Universal OpenRouter fallback**: if neither `MOONSHOT_API_KEY` nor
|
||||
`KIMI_API_KEY` is set but `OPENROUTER_API_KEY` is, requests go through
|
||||
OpenRouter using `OPENROUTER_MODEL` (default `openai/gpt-5.6-luna`). Moonshot
|
||||
Formula declarations and Fibers are not exposed through OpenRouter, so fallback
|
||||
mode answers from model knowledge without live Formula search. It is useful for
|
||||
interface diagnostics only and cannot satisfy Experiment 1-2 acceptance.
|
||||
|
||||
#### 3. Run the Agent
|
||||
|
||||
`main.py` provides a full CLI (Chinese help). List all flags:
|
||||
|
||||
```bash
|
||||
python main.py --help
|
||||
```
|
||||
|
||||
| Flag | Description | Default |
|
||||
|------|-------------|---------|
|
||||
| `query` | Question (positional); omit for interactive mode | none |
|
||||
| `--provider` | Backend: `kimi` (Moonshot Formula `web_search`, needs API key) / `offline-demo` (offline sample trace) | `kimi` |
|
||||
| `--model` | Model name | `kimi-k3` |
|
||||
| `--max-steps` | Max ReAct iterations | `5` |
|
||||
| `--base-url` | API base URL | `https://api.moonshot.cn/v1` |
|
||||
| `--api-key` | Kimi API key (else from env) | env |
|
||||
| `--output`, `-o` | Save question, ReAct trace, and answer as JSON | none |
|
||||
| `--quiet` | Do not stream ReAct trace live | stream on |
|
||||
|
||||
**Offline ReAct demo** (no API key; replays a sample trace to show think → act → observe):
|
||||
|
||||
```bash
|
||||
python main.py --provider offline-demo
|
||||
```
|
||||
|
||||
**Interactive mode** (ongoing dialogue):
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
**Single question** (streams think / act / observe steps):
|
||||
|
||||
```bash
|
||||
python main.py "2024年诺贝尔物理学奖获得者是谁?"
|
||||
python main.py "比特币现价" --max-steps 3 --output result.json
|
||||
```
|
||||
|
||||
**Guided quickstart**:
|
||||
|
||||
```bash
|
||||
python quickstart.py
|
||||
```
|
||||
|
||||
**Advanced examples**:
|
||||
|
||||
```bash
|
||||
python examples.py
|
||||
```
|
||||
|
||||
> At runtime the agent prints a **ReAct trace**: 💭 think → 🔧 act (`web_search`) → 👀 observe (Formula output) → ✅ final answer. Use `agent.get_trace()` for a structured trace, or `--output` to save JSON.
|
||||
|
||||
### Usage Examples
|
||||
|
||||
#### Basic usage
|
||||
|
||||
```python
|
||||
from agent import WebSearchAgent
|
||||
from config import Config
|
||||
|
||||
# Create Agent
|
||||
agent = WebSearchAgent(api_key=Config.get_api_key())
|
||||
|
||||
# Ask and get an answer
|
||||
question = "Python 3.12 有哪些新特性?"
|
||||
answer = agent.search_and_answer(question)
|
||||
print(answer)
|
||||
```
|
||||
|
||||
#### Advanced features
|
||||
|
||||
```bash
|
||||
python examples.py
|
||||
```
|
||||
|
||||
Includes:
|
||||
|
||||
- **Batch search**: multiple questions in one run
|
||||
- **Context-aware search**: supply background for sharper queries
|
||||
- **Comparative search**: search and compare items
|
||||
- **Fact check**: verify claims
|
||||
- **Research assistant**: deeper topic research
|
||||
|
||||
### Core Components
|
||||
|
||||
#### `agent.py` — core agent
|
||||
|
||||
- `WebSearchAgent`: main agent class
|
||||
- `search_and_answer()`: run the ReAct loop and produce an answer
|
||||
- `get_trace()`: structured ReAct trace of the last run (think / act / observe / final)
|
||||
- `_chat()`: chat with the Kimi API
|
||||
- `_get_system_prompt()`: system prompt defining agent behavior
|
||||
- `_get_tools()`: tool definitions (`$web_search`)
|
||||
- `search_impl()`: search implementation layer (extension point)
|
||||
- `format_trace_step()`: render one trace step as readable text
|
||||
- `run_offline_demo()`: offline sample-trace replay (no API key)
|
||||
|
||||
#### `config.py` — configuration
|
||||
|
||||
- API settings
|
||||
- Model selection
|
||||
- Search parameters
|
||||
|
||||
#### `main.py` — entry point
|
||||
|
||||
- `build_parser()`: argparse CLI (Chinese help; see `--help`)
|
||||
- `run_interactive_mode()`: interactive dialogue
|
||||
- `run_single_question()`: one-shot Q&A
|
||||
- Offline demo (`--provider offline-demo`) and JSON output (`--output`)
|
||||
- Session management
|
||||
|
||||
#### `quickstart.py` — guided demo
|
||||
|
||||
- `demo_search()`: demo search
|
||||
- `interactive_mode()`: simplified interactive mode
|
||||
- Colored output and user guidance
|
||||
- API key checks
|
||||
|
||||
#### `examples.py` — advanced demos
|
||||
|
||||
- `AdvancedWebSearchAgent`: extended agent
|
||||
- `batch_search()`: batch questions
|
||||
- `search_with_context()`: context-aware search
|
||||
- `comparative_search()`: multi-item comparison
|
||||
- `fact_check()`: fact verification
|
||||
- `example_research_assistant()`: deep research example
|
||||
|
||||
### Configuration Options
|
||||
|
||||
| Item | Description | Default |
|
||||
|------|-------------|---------|
|
||||
| `MOONSHOT_API_KEY` | Moonshot AI API key | required |
|
||||
| `KIMI_API_KEY` | Legacy key env name (compat) | optional |
|
||||
| `KIMI_BASE_URL` | API base URL | `https://api.moonshot.cn/v1` |
|
||||
| `DEFAULT_MODEL` | Default model | `kimi-k3` |
|
||||
| `MAX_SEARCH_ITERATIONS` | Max search iterations (in Config) | 5 |
|
||||
| `SEARCH_TIMEOUT` | Search timeout (seconds) | 30 |
|
||||
| `temperature` | Generation creativity | 0.6 |
|
||||
|
||||
### Technical Notes
|
||||
|
||||
#### Core stack
|
||||
|
||||
- **Kimi API**: Moonshot Kimi K3 (`kimi-k3`), a reasoning model with native web search
|
||||
- **Built-in tool calling**: Kimi `$web_search` built-in function
|
||||
- **Iterative search**: up to 5 rounds until information is sufficient
|
||||
- **Context management**: full dialogue history for multi-turn chat
|
||||
- **Temperature control**: adjustable creativity
|
||||
|
||||
#### Strengths
|
||||
|
||||
- **Live information**: up-to-date web results
|
||||
- **Intent understanding**: search aligned with the question
|
||||
- **Structured answers**: well-organized responses
|
||||
- **Extensible**: easy to add tools via `search_impl` and related hooks
|
||||
|
||||
### Development ideas (not implemented)
|
||||
|
||||
- [ ] Async search (e.g. aiohttp)
|
||||
- [ ] Result caching
|
||||
- [ ] More search backends via `search_impl`
|
||||
- [ ] Multilingual search
|
||||
- [ ] Result quality scoring
|
||||
- [ ] Search history
|
||||
- [ ] Retries (e.g. tenacity)
|
||||
- [ ] Better long-dialogue context management
|
||||
|
||||
### Caveats
|
||||
|
||||
1. **API limits**: respect Kimi quotas and rate limits
|
||||
2. **Search quality**: depends on Kimi’s search capability
|
||||
3. **Latency**: web search can take time
|
||||
4. **Accuracy**: double-check critical facts; the agent may still err
|
||||
|
||||
### Usage tips
|
||||
|
||||
1. **Ask clearly**: specific questions get better answers
|
||||
2. **Give context**: background helps when needed
|
||||
3. **Iterate**: refine with more detail if the first answer is weak
|
||||
4. **Set expectations**: answers are grounded in search results and may not cover everything
|
||||
|
||||
### Links
|
||||
|
||||
- [Kimi API docs](https://platform.moonshot.ai/docs)
|
||||
- [Web search tool docs](https://platform.moonshot.ai/docs/guide/use-web-search)
|
||||
- [Moonshot AI platform](https://platform.moonshot.ai/)
|
||||
|
||||
---
|
||||
|
||||
## 中文
|
||||
|
||||
### 概述
|
||||
|
||||
本项目实现了一个自主式 AI Agent,利用 Kimi(Moonshot AI)的内置 Web 搜索工具(search / crawl 能力),能够:
|
||||
|
||||
- **智能理解**:分析用户问题,识别关键信息需求
|
||||
- **自动搜索**:使用 Kimi 内置 `$web_search` 工具获取实时网络信息
|
||||
- **迭代搜索**:可多次调用搜索以获取更全面的信息
|
||||
- **智能总结**:综合多源信息,生成准确、全面的答案
|
||||
|
||||
对应书中**实验 1-2 ★:Kimi K3 原生 Agent 能力**,体现“模型即 Agent”与 ReAct(想 → 做 → 看)循环。
|
||||
|
||||
### Kimi 联网搜索服务状态
|
||||
|
||||
本示例依赖 Kimi 托管的 `$web_search` 服务。本仓库只会将内置工具返回的参数原样传回
|
||||
Kimi,并不会在本地执行搜索引擎。
|
||||
|
||||
Kimi 的[联网搜索官方文档](https://platform.kimi.ai/docs/guide/use-web-search)
|
||||
目前注明:该服务正在更新,近期不建议使用,并请开发者关注后续文档更新。排查本示例前,
|
||||
请先查看该页面确认最新服务状态。
|
||||
|
||||
如果工具观察结果只有 `search_id`,例如
|
||||
`{"search_result": {"search_id": "..."}}`,却没有实际搜索内容:
|
||||
|
||||
1. 查看 Kimi 联网搜索文档,确认当前服务状态。
|
||||
2. 稍后重试;如果只需查看 ReAct 流程,可运行
|
||||
`python main.py --provider offline-demo`,避免调用托管服务。
|
||||
3. 优先考虑外部工具/API 的可用性问题;仅凭这一响应,不能说明 Agent 循环或本地实现有误。
|
||||
|
||||
### 架构设计
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[用户问题] --> B{Agent 思考}
|
||||
B -->|需要搜索| C[调用 $web_search 工具]
|
||||
C --> D[Kimi 搜索引擎]
|
||||
D --> E[返回搜索结果]
|
||||
E --> F{信息充足?}
|
||||
F -->|否| G[继续调用 $web_search]
|
||||
G --> H[获取更多信息]
|
||||
H --> F
|
||||
F -->|是| I[生成最终答案]
|
||||
B -->|不需要搜索| J[直接回答]
|
||||
```
|
||||
|
||||
### 快速开始
|
||||
|
||||
#### 1. 安装依赖
|
||||
|
||||
```bash
|
||||
# 推荐在仓库根目录使用统一的第 1 章环境
|
||||
uv sync --locked --extra ch1
|
||||
|
||||
# 切换目录前先激活环境:
|
||||
# macOS/Linux:
|
||||
source .venv/bin/activate
|
||||
# Windows PowerShell:.venv\Scripts\Activate.ps1
|
||||
# Windows cmd:.venv\Scripts\activate.bat
|
||||
|
||||
# 未安装 uv 时可用 pip 兜底:
|
||||
# python -m pip install -e ".[ch1]"
|
||||
|
||||
# 进入本实验目录,后续命令都在这里运行
|
||||
cd chapter1/web-search-agent
|
||||
|
||||
# 迁移期间仍支持单项目兼容路径:
|
||||
# python -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
#### 2. 配置 API Key
|
||||
|
||||
从 [Moonshot AI 平台](https://platform.moonshot.ai/) 获取 API Key,然后设置环境变量:
|
||||
|
||||
```bash
|
||||
export MOONSHOT_API_KEY='your-api-key-here'
|
||||
```
|
||||
|
||||
或创建 `.env` 文件:
|
||||
|
||||
```env
|
||||
MOONSHOT_API_KEY=your-api-key-here
|
||||
```
|
||||
|
||||
**注意**: 为了向后兼容,系统也支持使用 `KIMI_API_KEY` 环境变量。
|
||||
|
||||
**通用兜底(OpenRouter)**: 若未设置 `MOONSHOT_API_KEY`/`KIMI_API_KEY` 但设置了 `OPENROUTER_API_KEY`,请求会自动改走 OpenRouter,使用 `OPENROUTER_MODEL`(默认 `openai/gpt-5.6-luna`)。**重要限制**:Kimi 内置的 `$web_search` 工具是 Moonshot 专有能力,在 OpenRouter 上不可用——因此兜底模式下模型仅凭自身知识作答,**没有实时联网搜索**。如需真正的联网搜索,请使用 Moonshot 主 key。
|
||||
|
||||
#### 3. 运行 Agent
|
||||
|
||||
`main.py` 提供了完整的命令行接口(中文帮助)。查看全部参数:
|
||||
|
||||
```bash
|
||||
python main.py --help
|
||||
```
|
||||
|
||||
| 参数 | 说明 | 默认值 |
|
||||
|------|------|--------|
|
||||
| `query` | 要提问的问题(位置参数);省略则进入交互模式 | 无 |
|
||||
| `--provider` | 搜索后端:`kimi`(调用内置 `$web_search`,需 API Key)/ `offline-demo`(离线示例轨迹) | `kimi` |
|
||||
| `--model` | 模型名称 | `kimi-k3` |
|
||||
| `--max-steps` | 最大 ReAct 迭代次数 | `5` |
|
||||
| `--base-url` | API 基础 URL | `https://api.moonshot.cn/v1` |
|
||||
| `--api-key` | Kimi API Key(默认读环境变量) | 环境变量 |
|
||||
| `--output`, `-o` | 将问题、ReAct 轨迹与答案保存为 JSON | 无 |
|
||||
| `--quiet` | 不实时打印 ReAct 轨迹 | 打印 |
|
||||
|
||||
**离线演示 ReAct 循环**(无需 API Key,回放示例轨迹,直观展示“想→做→看”):
|
||||
|
||||
```bash
|
||||
python main.py --provider offline-demo
|
||||
```
|
||||
|
||||
**交互模式**(持续对话):
|
||||
|
||||
```bash
|
||||
python main.py
|
||||
```
|
||||
|
||||
**单次问答**(运行时逐步打印思考/行动/观察轨迹):
|
||||
|
||||
```bash
|
||||
python main.py "2024年诺贝尔物理学奖获得者是谁?"
|
||||
python main.py "比特币现价" --max-steps 3 --output result.json
|
||||
```
|
||||
|
||||
**快速体验**(引导式交互):
|
||||
|
||||
```bash
|
||||
python quickstart.py
|
||||
```
|
||||
|
||||
**高级示例**:
|
||||
|
||||
```bash
|
||||
python examples.py
|
||||
```
|
||||
|
||||
> 运行时会实时打印 **ReAct 轨迹**:💭 思考 → 🔧 行动(调用 `$web_search`)→ 👀 观察(搜索结果)→ ✅ 最终答案,对应本章讲的“想→做→看”循环。`agent.get_trace()` 可获取结构化轨迹,`--output` 可将其存为 JSON。
|
||||
|
||||
### 使用示例
|
||||
|
||||
#### 基础使用
|
||||
|
||||
```python
|
||||
from agent import WebSearchAgent
|
||||
from config import Config
|
||||
|
||||
# 创建 Agent
|
||||
agent = WebSearchAgent(api_key=Config.get_api_key())
|
||||
|
||||
# 提问并获取答案
|
||||
question = "Python 3.12 有哪些新特性?"
|
||||
answer = agent.search_and_answer(question)
|
||||
print(answer)
|
||||
```
|
||||
|
||||
#### 高级功能
|
||||
|
||||
```bash
|
||||
python examples.py
|
||||
```
|
||||
|
||||
包含:
|
||||
|
||||
- **批量搜索**:同时搜索多个问题
|
||||
- **带上下文搜索**:提供背景信息进行更精准的搜索
|
||||
- **比较搜索**:搜索并比较多个项目
|
||||
- **事实核查**:验证陈述的真实性
|
||||
- **研究助手**:深度研究某个主题
|
||||
|
||||
### 核心组件
|
||||
|
||||
#### `agent.py` — 核心 Agent 实现
|
||||
|
||||
- `WebSearchAgent`: 主要的 Agent 类
|
||||
- `search_and_answer()`: 执行 ReAct 循环并生成答案的主方法
|
||||
- `get_trace()`: 返回上一次运行的结构化 ReAct 轨迹(思考/行动/观察/最终答案)
|
||||
- `_chat()`: 与 Kimi API 进行对话交互
|
||||
- `_get_system_prompt()`: 获取系统提示,定义 Agent 行为
|
||||
- `_get_tools()`: 定义可用的工具(`$web_search`)
|
||||
- `search_impl()`: 搜索实现的抽象层,便于扩展
|
||||
- `format_trace_step()`: 将一条轨迹步骤渲染为可读文本
|
||||
- `run_offline_demo()`: 离线回放示例轨迹,无需 API Key 即可演示 ReAct 循环
|
||||
|
||||
#### `config.py` — 配置管理
|
||||
|
||||
- API 配置
|
||||
- 模型选择
|
||||
- 搜索参数设置
|
||||
|
||||
#### `main.py` — 主程序入口
|
||||
|
||||
- `build_parser()`: argparse 命令行接口(中文帮助,见 `--help`)
|
||||
- `run_interactive_mode()`: 交互式对话模式
|
||||
- `run_single_question()`: 单次问答模式
|
||||
- 离线演示模式(`--provider offline-demo`)与 JSON 结果输出(`--output`)
|
||||
- 会话管理
|
||||
|
||||
#### `quickstart.py` — 快速体验脚本
|
||||
|
||||
- `demo_search()`: 演示搜索功能
|
||||
- `interactive_mode()`: 简化的交互模式
|
||||
- 彩色输出和用户引导
|
||||
- API Key 配置检查
|
||||
|
||||
#### `examples.py` — 高级示例
|
||||
|
||||
- `AdvancedWebSearchAgent`: 扩展功能的 Agent 类
|
||||
- `batch_search()`: 批量处理多个问题
|
||||
- `search_with_context()`: 带上下文的搜索
|
||||
- `comparative_search()`: 比较多个项目
|
||||
- `fact_check()`: 事实验证功能
|
||||
- `example_research_assistant()`: 深度研究示例
|
||||
|
||||
### 配置选项
|
||||
|
||||
| 配置项 | 说明 | 默认值 |
|
||||
|--------|------|--------|
|
||||
| `MOONSHOT_API_KEY` | Moonshot AI API 密钥 | 必填 |
|
||||
| `KIMI_API_KEY` | 旧版 API 密钥变量名(向后兼容) | 可选 |
|
||||
| `KIMI_BASE_URL` | API 基础 URL | `https://api.moonshot.cn/v1` |
|
||||
| `DEFAULT_MODEL` | 默认模型 | `kimi-k3` |
|
||||
| `MAX_SEARCH_ITERATIONS` | 最大搜索迭代次数(Config 中设置) | 5 |
|
||||
| `SEARCH_TIMEOUT` | 搜索超时时间(秒) | 30 |
|
||||
| `temperature` | 控制生成内容的创造性 | 0.6 |
|
||||
|
||||
### 技术特点
|
||||
|
||||
#### 核心技术
|
||||
|
||||
- **Kimi API**: 使用 Moonshot AI 的最新 Kimi K3 模型(`kimi-k3`,原生联网搜索的推理模型)
|
||||
- **内置工具调用**: 利用 Kimi 的 `$web_search` 内置函数
|
||||
- **迭代式搜索**: 支持多轮搜索直到获得充分信息(最多 5 次迭代)
|
||||
- **上下文管理**: 维护完整对话历史,支持连续对话
|
||||
- **温度控制**: 支持调整生成内容的创造性(temperature 参数)
|
||||
|
||||
#### 优势
|
||||
|
||||
- **实时信息**: 获取最新的网络信息
|
||||
- **智能理解**: 理解用户意图,精准搜索
|
||||
- **结构化输出**: 生成组织良好的答案
|
||||
- **可扩展性**: 易于添加新功能和工具
|
||||
|
||||
### 开发计划(尚未实现)
|
||||
|
||||
- [ ] 添加异步搜索支持(使用 aiohttp)
|
||||
- [ ] 实现搜索结果缓存机制
|
||||
- [ ] 支持更多搜索后端(通过 `search_impl` 扩展)
|
||||
- [ ] 支持多语言搜索
|
||||
- [ ] 添加搜索结果质量评分
|
||||
- [ ] 实现搜索历史记录
|
||||
- [ ] 集成重试机制(使用 tenacity)
|
||||
- [ ] 优化长对话的上下文管理
|
||||
|
||||
### 注意事项
|
||||
|
||||
1. **API 限制**: 请注意 Kimi API 的调用限制和配额
|
||||
2. **搜索质量**: 搜索结果质量依赖于 Kimi 的搜索能力
|
||||
3. **响应时间**: 网络搜索可能需要一定时间,请耐心等待
|
||||
4. **内容准确性**: Agent 会尽力提供准确信息,但建议对重要信息进行二次验证
|
||||
|
||||
### 使用建议
|
||||
|
||||
1. **明确问题**: 提供清晰、具体的问题以获得更好的答案
|
||||
2. **提供上下文**: 必要时提供背景信息帮助 Agent 理解
|
||||
3. **迭代优化**: 如果答案不满意,可以提供更多细节重新提问
|
||||
4. **合理期望**: Agent 基于搜索结果回答,可能无法回答所有问题
|
||||
|
||||
### 相关链接
|
||||
|
||||
- [Kimi API 文档](https://platform.moonshot.ai/docs)
|
||||
- [Web 搜索工具文档](https://platform.moonshot.ai/docs/guide/use-web-search)
|
||||
- [Moonshot AI 平台](https://platform.moonshot.ai/)
|
||||
|
||||
---
|
||||
|
||||
## Notes / 说明
|
||||
|
||||
- License: MIT.
|
||||
许可证:MIT。
|
||||
- Author / 作者: AI Agent 实战训练营;version / 版本: 1.0.0.
|
||||
- Prefer `--provider offline-demo` first if you only want to see the ReAct shape without spending API quota.
|
||||
若只想先看 ReAct 形态、不消耗配额,优先运行 `--provider offline-demo`。
|
||||
- Live search requires a Moonshot key; OpenRouter fallback has no `$web_search`.
|
||||
真正联网搜索必须使用 Moonshot Key;OpenRouter 兜底没有 `$web_search`。
|
||||
@@ -0,0 +1,598 @@
|
||||
"""
|
||||
Kimi Web Search Agent
|
||||
一个基于 Kimi API 的智能搜索 Agent,能够理解用户问题,通过搜索引擎获取信息,并总结出答案。
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import List, Dict, Any, Optional
|
||||
from openai import OpenAI
|
||||
from openai.types.chat.chat_completion import Choice
|
||||
import logging
|
||||
import os
|
||||
import requests
|
||||
import time
|
||||
|
||||
# 设置日志
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _reasoning_safe_temperature(model, requested=1.0):
|
||||
"""Reasoning models (Kimi K3, GPT-5, ...) only accept temperature=1.
|
||||
Return 1 for those; otherwise the requested value so non-reasoning
|
||||
providers (Doubao, DeepSeek, older Moonshot) are unchanged."""
|
||||
m = str(model or "").lower().replace("/", "-")
|
||||
return 1 if ("kimi-k3" in m or "gpt-5" in m) else requested
|
||||
|
||||
|
||||
# ReAct 轨迹的步骤类型与展示标签(思考 → 行动 → 观察 → 最终答案)
|
||||
STEP_LABELS = {
|
||||
"thought": ("💭", "思考"),
|
||||
"action": ("🔧", "行动"),
|
||||
"observation": ("👀", "观察"),
|
||||
"answer": ("✅", "最终答案"),
|
||||
}
|
||||
|
||||
|
||||
def format_trace_step(step: Dict[str, Any], max_len: int = 500) -> str:
|
||||
"""把一条 ReAct 轨迹步骤渲染成一行可读文本。
|
||||
|
||||
这正是本章强调的“轨迹(trajectory)”——用户消息、模型思考、工具调用、
|
||||
工具结果都被清晰地区分开来,让 ReAct 循环“想→做→看”一目了然。
|
||||
"""
|
||||
icon, label = STEP_LABELS.get(step["type"], ("•", step["type"]))
|
||||
prefix = f"{icon} [{step.get('iteration', '-')}] {label}"
|
||||
|
||||
if step["type"] == "action":
|
||||
args = json.dumps(step.get("args", {}), ensure_ascii=False)
|
||||
return f"{prefix}: 调用工具 {step.get('tool')} 参数={args}"
|
||||
|
||||
content = str(step.get("content", "")).strip()
|
||||
if len(content) > max_len:
|
||||
content = content[:max_len] + f"…(省略 {len(content) - max_len} 字)"
|
||||
return f"{prefix}: {content}"
|
||||
|
||||
|
||||
def search_impl(arguments: Dict[str, Any]) -> Any:
|
||||
"""
|
||||
When using the search tool provided by Moonshot AI, you just need to return the arguments as they are,
|
||||
without any additional processing logic.
|
||||
|
||||
But if you want to use other models and keep the internet search functionality, you just need to modify
|
||||
the implementation here (for example, calling search and fetching web page content), the function signature
|
||||
remains the same and still works.
|
||||
|
||||
This ensures maximum compatibility, allowing you to switch between different models without making
|
||||
destructive changes to the code.
|
||||
"""
|
||||
return arguments
|
||||
|
||||
|
||||
# search_and_answer 不抛异常,而是以字符串形式返回失败兜底文案。
|
||||
# 下列前缀 / 文案是判断“一次搜索是否失败”的唯一来源,供调用方(如
|
||||
# examples.batch_search)复用,避免把失败响应误判为 success。
|
||||
SEARCH_ERROR_PREFIX = "搜索过程中出现错误"
|
||||
MAX_ITERATIONS_MESSAGE = "抱歉,搜索过程超过了最大迭代次数,请稍后重试。"
|
||||
NO_INFO_MESSAGE = "抱歉,我无法获取足够的信息来回答您的问题。"
|
||||
|
||||
|
||||
def is_failure_answer(answer: str) -> bool:
|
||||
"""判断 search_and_answer 的返回是否为失败兜底(未能正常作答)。"""
|
||||
return (
|
||||
answer.startswith(SEARCH_ERROR_PREFIX)
|
||||
or answer == MAX_ITERATIONS_MESSAGE
|
||||
or answer == NO_INFO_MESSAGE
|
||||
)
|
||||
|
||||
|
||||
class WebSearchAgent:
|
||||
"""
|
||||
Web Search Agent - 使用 Kimi Formula API 官方搜索工具。
|
||||
|
||||
kimi-k3 的当前官方路径是标准 ``function`` tool 声明加
|
||||
``moonshot/web-search:latest`` Formula Fiber 执行。
|
||||
"""
|
||||
|
||||
def __init__(self, api_key: str = None, base_url: str = "https://api.moonshot.cn/v1",
|
||||
model: str = "kimi-k3", verbose: bool = False):
|
||||
"""
|
||||
初始化 Agent
|
||||
|
||||
Args:
|
||||
api_key: Kimi API key (如果不提供,从环境变量获取)
|
||||
base_url: API 基础 URL
|
||||
model: 使用的模型名称(默认 kimi-k3)
|
||||
verbose: 是否实时打印 ReAct 轨迹(思考/行动/观察)
|
||||
"""
|
||||
# 优先使用传入的 api_key,否则从环境变量获取
|
||||
# Moonshot 为主,OpenRouter 为通用兜底(当 MOONSHOT_API_KEY 缺失时启用)
|
||||
from config import resolve_llm_backend, Config
|
||||
primary_key = api_key or os.environ.get("MOONSHOT_API_KEY") or os.environ.get("KIMI_API_KEY")
|
||||
resolved_key, resolved_base_url, model, self.using_openrouter = \
|
||||
resolve_llm_backend(primary_key, base_url, model)
|
||||
if self.using_openrouter:
|
||||
logger.info(
|
||||
f"MOONSHOT_API_KEY 未设置,改用 OpenRouter 兜底(模型: {model})。"
|
||||
"注意:Moonshot Formula web_search 工具在 OpenRouter 上不可用,"
|
||||
"此模式下模型将仅凭自身知识作答,不做实时联网搜索。"
|
||||
)
|
||||
|
||||
self.client = OpenAI(
|
||||
api_key=resolved_key,
|
||||
base_url=resolved_base_url,
|
||||
# 应用配置的搜索超时,避免后端挂起时请求默认阻塞约 10 分钟
|
||||
timeout=Config.SEARCH_TIMEOUT,
|
||||
)
|
||||
self._api_key = resolved_key
|
||||
self.base_url = resolved_base_url
|
||||
self.model = model
|
||||
self.verbose = verbose
|
||||
self.conversation_history = []
|
||||
# ReAct 轨迹:按顺序记录每一步的思考/行动/观察,便于展示与调试
|
||||
self.trace: List[Dict[str, Any]] = []
|
||||
# Credential-free provider requests/responses for Experiment 1-2
|
||||
# acceptance. Search IDs in tool arguments are intentionally retained:
|
||||
# they prove that Moonshot's hosted built-in tool actually executed.
|
||||
self.api_turns: List[Dict[str, Any]] = []
|
||||
self.formula_uri = "moonshot/web-search:latest"
|
||||
self._formula_tools: Optional[List[Dict[str, Any]]] = None
|
||||
self._request_timeout = Config.SEARCH_TIMEOUT
|
||||
self.temperature = 0.6
|
||||
# 推理模型(Kimi K3)需要充足的输出预算,避免最终答案被截断
|
||||
self.max_tokens = 32768
|
||||
|
||||
def _emit(self, step: Dict[str, Any]):
|
||||
"""记录一条 ReAct 轨迹步骤,并在 verbose 模式下实时打印。"""
|
||||
self.trace.append(step)
|
||||
if self.verbose:
|
||||
print(format_trace_step(step))
|
||||
|
||||
def _get_tools(self) -> List[Dict[str, Any]]:
|
||||
"""Fetch and cache Kimi's authoritative Formula declaration."""
|
||||
if getattr(self, "using_openrouter", False):
|
||||
return []
|
||||
if self._formula_tools is not None:
|
||||
return self._formula_tools
|
||||
|
||||
url = (
|
||||
f"{self.base_url.rstrip('/')}/formulas/"
|
||||
f"{self.formula_uri}/tools"
|
||||
)
|
||||
started = time.monotonic()
|
||||
response = None
|
||||
try:
|
||||
response = requests.get(
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {self._api_key}"},
|
||||
timeout=self._request_timeout,
|
||||
)
|
||||
payload = response.json()
|
||||
response.raise_for_status()
|
||||
tools = payload.get("tools")
|
||||
if not isinstance(tools, list) or not tools:
|
||||
raise RuntimeError("Formula declaration response has no tools")
|
||||
if not any(
|
||||
tool.get("type") == "function"
|
||||
and tool.get("function", {}).get("name") == "web_search"
|
||||
for tool in tools
|
||||
):
|
||||
raise RuntimeError(
|
||||
"Formula declaration does not contain function web_search"
|
||||
)
|
||||
except Exception as exc:
|
||||
error_payload: Dict[str, Any] = {
|
||||
"class": type(exc).__name__,
|
||||
"message": str(exc),
|
||||
}
|
||||
if response is not None:
|
||||
try:
|
||||
error_payload["response"] = response.json()
|
||||
except ValueError:
|
||||
error_payload["response_text"] = response.text
|
||||
self.api_turns.append({
|
||||
"kind": "formula_tools",
|
||||
"formula_uri": self.formula_uri,
|
||||
"request": {"method": "GET", "url": url},
|
||||
"http_status": getattr(response, "status_code", None),
|
||||
"elapsed_seconds": round(time.monotonic() - started, 6),
|
||||
"error": error_payload,
|
||||
})
|
||||
raise
|
||||
|
||||
self.api_turns.append({
|
||||
"kind": "formula_tools",
|
||||
"formula_uri": self.formula_uri,
|
||||
"request": {"method": "GET", "url": url},
|
||||
"http_status": response.status_code,
|
||||
"response": payload,
|
||||
"elapsed_seconds": round(time.monotonic() - started, 6),
|
||||
})
|
||||
self._formula_tools = tools
|
||||
return tools
|
||||
|
||||
def _execute_formula(self, name: str, raw_arguments: str) -> str:
|
||||
"""Execute one Kimi Formula Fiber exactly as the model requested."""
|
||||
if self.using_openrouter:
|
||||
raise RuntimeError("Kimi Formula tools are unavailable on OpenRouter")
|
||||
|
||||
url = (
|
||||
f"{self.base_url.rstrip('/')}/formulas/"
|
||||
f"{self.formula_uri}/fibers"
|
||||
)
|
||||
body = {"name": name, "arguments": raw_arguments}
|
||||
started = time.monotonic()
|
||||
response = None
|
||||
try:
|
||||
response = requests.post(
|
||||
url,
|
||||
headers={"Authorization": f"Bearer {self._api_key}"},
|
||||
json=body,
|
||||
timeout=self._request_timeout,
|
||||
)
|
||||
payload = response.json()
|
||||
response.raise_for_status()
|
||||
if payload.get("status") != "succeeded":
|
||||
raise RuntimeError(
|
||||
f"Formula Fiber did not succeed: {payload.get('status')!r}"
|
||||
)
|
||||
context = payload.get("context") or {}
|
||||
result = context.get("output")
|
||||
if result in (None, ""):
|
||||
result = context.get("encrypted_output")
|
||||
if result in (None, ""):
|
||||
raise RuntimeError("Succeeded Formula Fiber returned no output")
|
||||
except Exception as exc:
|
||||
error_payload: Dict[str, Any] = {
|
||||
"class": type(exc).__name__,
|
||||
"message": str(exc),
|
||||
}
|
||||
if response is not None:
|
||||
try:
|
||||
error_payload["response"] = response.json()
|
||||
except ValueError:
|
||||
error_payload["response_text"] = response.text
|
||||
self.api_turns.append({
|
||||
"kind": "formula_fiber",
|
||||
"formula_uri": self.formula_uri,
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": url,
|
||||
"body": body,
|
||||
},
|
||||
"http_status": getattr(response, "status_code", None),
|
||||
"elapsed_seconds": round(time.monotonic() - started, 6),
|
||||
"error": error_payload,
|
||||
})
|
||||
raise
|
||||
|
||||
self.api_turns.append({
|
||||
"kind": "formula_fiber",
|
||||
"formula_uri": self.formula_uri,
|
||||
"request": {"method": "POST", "url": url, "body": body},
|
||||
"http_status": response.status_code,
|
||||
"response": payload,
|
||||
"elapsed_seconds": round(time.monotonic() - started, 6),
|
||||
})
|
||||
if isinstance(result, str):
|
||||
return result
|
||||
return json.dumps(result, ensure_ascii=False)
|
||||
|
||||
def _get_system_prompt(self) -> str:
|
||||
"""
|
||||
获取系统提示
|
||||
"""
|
||||
return f"""你是 Kimi,一个智能搜索助手。
|
||||
|
||||
请按照以下步骤处理:
|
||||
1. 分析用户问题,识别关键信息需求
|
||||
2. 使用 web_search 官方工具搜索相关信息
|
||||
3. 如果需要更多信息,可以多次调用搜索工具
|
||||
4. 综合所有信息,生成准确、全面的答案
|
||||
|
||||
注意:
|
||||
- 搜索时使用精准的关键词
|
||||
- 优先获取最新、最权威的信息
|
||||
- 答案要结构清晰,有理有据
|
||||
"""
|
||||
|
||||
def _chat(self, messages: List[Dict[str, Any]]) -> Choice:
|
||||
"""
|
||||
调用 Kimi API 进行对话
|
||||
|
||||
Args:
|
||||
messages: 消息列表
|
||||
|
||||
Returns:
|
||||
API 响应的 Choice 对象
|
||||
"""
|
||||
kwargs = dict(
|
||||
model=self.model,
|
||||
messages=messages,
|
||||
temperature=_reasoning_safe_temperature(self.model, self.temperature),
|
||||
# Kimi K3 是推理模型,会先产出较长的 reasoning_content,需要给最终回答
|
||||
# 留足输出预算(Moonshot 要求 max_tokens>=2048),否则答案可能被截断为空。
|
||||
max_tokens=self.max_tokens,
|
||||
)
|
||||
if str(self.model).lower() == "kimi-k3":
|
||||
kwargs["reasoning_effort"] = "max"
|
||||
tools = self._get_tools()
|
||||
if tools: # OpenRouter 兜底时无内置搜索工具,省略 tools 参数
|
||||
kwargs["tools"] = tools
|
||||
started = time.monotonic()
|
||||
try:
|
||||
completion = self.client.chat.completions.create(**kwargs)
|
||||
except Exception as exc:
|
||||
self.api_turns.append({
|
||||
"kind": "chat_completion",
|
||||
"request": json.loads(json.dumps(kwargs, ensure_ascii=False, default=str)),
|
||||
"elapsed_seconds": round(time.monotonic() - started, 6),
|
||||
"error": {"class": type(exc).__name__, "message": str(exc)},
|
||||
})
|
||||
raise
|
||||
response = (
|
||||
completion.model_dump() if hasattr(completion, "model_dump")
|
||||
else completion.dict() if hasattr(completion, "dict")
|
||||
else {"raw_response": str(completion)}
|
||||
)
|
||||
self.api_turns.append({
|
||||
"kind": "chat_completion",
|
||||
"request": json.loads(json.dumps(kwargs, ensure_ascii=False, default=str)),
|
||||
"response": json.loads(json.dumps(response, ensure_ascii=False, default=str)),
|
||||
"elapsed_seconds": round(time.monotonic() - started, 6),
|
||||
})
|
||||
return completion.choices[0]
|
||||
|
||||
def search_and_answer(self, user_question: str, max_iterations: int = 5) -> str:
|
||||
"""
|
||||
执行搜索并生成答案
|
||||
|
||||
Args:
|
||||
user_question: 用户问题
|
||||
max_iterations: 最大搜索迭代次数(防止无限循环)
|
||||
|
||||
Returns:
|
||||
最终答案
|
||||
"""
|
||||
# 构建系统提示
|
||||
system_prompt = self._get_system_prompt()
|
||||
|
||||
# 重置对话历史并添加新的系统提示
|
||||
self.conversation_history = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": user_question}
|
||||
]
|
||||
# 重置 ReAct 轨迹
|
||||
self.trace = []
|
||||
self.api_turns = []
|
||||
# Each independent question keeps its own real declaration receipt.
|
||||
self._formula_tools = None
|
||||
logger.info("开始调用 Kimi 搜索工具...")
|
||||
|
||||
try:
|
||||
finish_reason = None
|
||||
iteration = 0
|
||||
|
||||
# 循环处理,直到获得最终答案或达到最大迭代次数
|
||||
while (finish_reason is None or finish_reason == "tool_calls") and iteration < max_iterations:
|
||||
iteration += 1
|
||||
logger.info(f"迭代 {iteration}/{max_iterations}")
|
||||
|
||||
# 调用 Kimi API
|
||||
choice = self._chat(self.conversation_history)
|
||||
finish_reason = choice.finish_reason
|
||||
|
||||
# 捕获模型的思考过程(Kimi K3 等推理模型通过 reasoning_content 暴露思考模式)
|
||||
reasoning = getattr(choice.message, "reasoning_content", None)
|
||||
if reasoning:
|
||||
self._emit({"iteration": iteration, "type": "thought", "content": reasoning})
|
||||
|
||||
if finish_reason == "tool_calls":
|
||||
# 处理工具调用
|
||||
logger.info(f"模型请求调用 {len(choice.message.tool_calls)} 个工具")
|
||||
|
||||
# 添加助手的消息(包含工具调用)到历史。
|
||||
# 注意:必须把消息重建为纯 dict,而不是直接塞入 SDK 返回的
|
||||
# pydantic message 对象——后者会附带 reasoning_content / refusal
|
||||
# 等额外字段,回传给 Moonshot 时会触发 "tokenization failed" 400 错误。
|
||||
self.conversation_history.append({
|
||||
"role": "assistant",
|
||||
"content": choice.message.content or "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": tc.id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": tc.function.name,
|
||||
"arguments": tc.function.arguments,
|
||||
},
|
||||
}
|
||||
for tc in choice.message.tool_calls
|
||||
],
|
||||
})
|
||||
|
||||
# 执行每个工具调用
|
||||
for tool_call in choice.message.tool_calls:
|
||||
tool_call_name = tool_call.function.name
|
||||
try:
|
||||
tool_call_arguments = json.loads(
|
||||
tool_call.function.arguments or "{}"
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
# Models sometimes emit slightly invalid JSON; match
|
||||
# chapter4 async-agent and keep the ReAct loop alive.
|
||||
tool_call_arguments = {}
|
||||
logger.warning(
|
||||
"工具参数不是合法 JSON,已按空对象继续: %r",
|
||||
tool_call.function.arguments,
|
||||
)
|
||||
|
||||
logger.info(f"执行工具: {tool_call_name}, 参数: {tool_call_arguments}")
|
||||
# 行动:记录一次工具调用
|
||||
self._emit({"iteration": iteration, "type": "action",
|
||||
"tool": tool_call_name, "args": tool_call_arguments})
|
||||
|
||||
if tool_call_name == "web_search":
|
||||
# Formula requires the original serialized
|
||||
# arguments, even though the parsed copy above is
|
||||
# retained for a readable ReAct trace.
|
||||
tool_result = self._execute_formula(
|
||||
tool_call_name,
|
||||
tool_call.function.arguments or "{}",
|
||||
)
|
||||
else:
|
||||
tool_result = f"Error: unable to find tool by name '{tool_call_name}'"
|
||||
|
||||
tool_content = (
|
||||
tool_result
|
||||
if isinstance(tool_result, str)
|
||||
else json.dumps(tool_result, ensure_ascii=False)
|
||||
)
|
||||
# 观察:记录工具返回结果
|
||||
self._emit({"iteration": iteration, "type": "observation",
|
||||
"tool": tool_call_name, "content": tool_content})
|
||||
# 构建工具响应消息并添加到历史
|
||||
self.conversation_history.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call.id,
|
||||
"content": tool_content
|
||||
})
|
||||
elif finish_reason == "length":
|
||||
# 输出预算(max_tokens)耗尽导致截断:返回已生成内容并明确标注,
|
||||
# 而不是把半截答案当作完整答案,也不误报“无法获取足够信息”
|
||||
# (content 为空时,思考过程已耗尽整个预算)。
|
||||
partial = (choice.message.content or "").strip()
|
||||
logger.warning("回答因达到 max_tokens 上限被截断 (finish_reason=length)")
|
||||
note = "(注意:回答因达到 max_tokens 上限被截断,请增大 max_tokens 后重试。)"
|
||||
final = f"{partial}\n\n{note}" if partial else note
|
||||
self._emit({"iteration": iteration, "type": "answer", "content": final})
|
||||
# 存入历史时保留截断提示(final),否则 get_conversation_history()
|
||||
# 会丢失截断语义,后续复用历史时可能把不完整回答当作普通回答。
|
||||
self.conversation_history.append({
|
||||
"role": "assistant",
|
||||
"content": final
|
||||
})
|
||||
return final
|
||||
else:
|
||||
# 获得最终答案
|
||||
if choice.message.content:
|
||||
answer = choice.message.content
|
||||
logger.info("成功生成答案")
|
||||
self._emit({"iteration": iteration, "type": "answer", "content": answer})
|
||||
|
||||
# 添加最终答案到历史
|
||||
self.conversation_history.append({
|
||||
"role": "assistant",
|
||||
"content": answer
|
||||
})
|
||||
|
||||
return answer
|
||||
|
||||
# 如果达到最大迭代次数仍未完成
|
||||
if iteration >= max_iterations:
|
||||
logger.warning(f"达到最大迭代次数 {max_iterations}")
|
||||
return MAX_ITERATIONS_MESSAGE
|
||||
|
||||
return NO_INFO_MESSAGE
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"{SEARCH_ERROR_PREFIX}: {str(e)}")
|
||||
return f"{SEARCH_ERROR_PREFIX}: {str(e)}"
|
||||
|
||||
def clear_history(self):
|
||||
"""清空对话历史"""
|
||||
self.conversation_history = []
|
||||
logger.info("对话历史已清空")
|
||||
|
||||
def get_conversation_history(self) -> List[Dict[str, str]]:
|
||||
"""获取对话历史"""
|
||||
return self.conversation_history
|
||||
|
||||
def get_trace(self) -> List[Dict[str, Any]]:
|
||||
"""获取上一次 search_and_answer 的 ReAct 轨迹(思考/行动/观察/最终答案)"""
|
||||
return self.trace
|
||||
|
||||
def get_api_turns(self) -> List[Dict[str, Any]]:
|
||||
"""Return detached real-provider evidence for the latest question."""
|
||||
return json.loads(json.dumps(self.api_turns, ensure_ascii=False, default=str))
|
||||
|
||||
def set_temperature(self, temperature: float):
|
||||
"""
|
||||
设置温度参数
|
||||
|
||||
Args:
|
||||
temperature: 温度值 (0.0 - 2.0)
|
||||
"""
|
||||
if 0.0 <= temperature <= 2.0:
|
||||
self.temperature = temperature
|
||||
logger.info(f"温度设置为: {temperature}")
|
||||
else:
|
||||
logger.warning(f"无效的温度值: {temperature},应在 0.0 到 2.0 之间")
|
||||
|
||||
|
||||
def run_offline_demo(question: str = "Moonshot AI 的 Context Caching 是什么技术?",
|
||||
verbose: bool = True) -> Dict[str, Any]:
|
||||
"""离线演示 ReAct 循环——无需 API Key 或联网。
|
||||
|
||||
本函数**不调用真实搜索**,而是回放一段“示例轨迹”,用来直观展示本章讲的
|
||||
“想→做→看→想→做→看”循环:模型先思考,再调用 web_search 行动,观察结果后
|
||||
继续思考,最终综合出答案。轨迹内容仅为教学示例,不代表真实搜索返回。
|
||||
|
||||
Returns:
|
||||
包含 question / trace / answer 的字典。
|
||||
"""
|
||||
trace: List[Dict[str, Any]] = [
|
||||
{"iteration": 1, "type": "thought",
|
||||
"content": "用户想了解 Context Caching。这是 Moonshot 的特性,我需要先搜索官方说明,确认它的定义和作用。"},
|
||||
{"iteration": 1, "type": "action", "tool": "web_search",
|
||||
"args": {"query": "Moonshot AI Context Caching 是什么"}},
|
||||
{"iteration": 1, "type": "observation", "tool": "web_search",
|
||||
"content": "(示例结果)Context Caching 是一种上下文缓存机制:把重复使用的前缀"
|
||||
"(如长系统提示、文档)缓存在服务端,后续请求命中缓存即可复用,"
|
||||
"从而降低重复计算与费用。"},
|
||||
{"iteration": 2, "type": "thought",
|
||||
"content": "已知大致定义,但还缺少适用场景。再搜一次它的典型用途以便答得更完整。"},
|
||||
{"iteration": 2, "type": "action", "tool": "web_search",
|
||||
"args": {"query": "Context Caching 适用场景 计费"}},
|
||||
{"iteration": 2, "type": "observation", "tool": "web_search",
|
||||
"content": "(示例结果)常见于多轮对话、长文档反复问答、固定系统提示等场景;"
|
||||
"命中缓存的 token 通常按更低价格计费,并能显著降低首字延迟。"},
|
||||
{"iteration": 3, "type": "answer",
|
||||
"content": "Context Caching(上下文缓存)是 Moonshot AI 提供的一种机制:将重复使用的"
|
||||
"上下文前缀缓存在服务端,后续请求复用缓存内容,从而降低重复计算、减少费用、"
|
||||
"并加快响应。它特别适合长系统提示、长文档反复问答、多轮对话等场景。"
|
||||
"(本段来自离线示例轨迹,非真实搜索结果。)"},
|
||||
]
|
||||
|
||||
if verbose:
|
||||
for step in trace:
|
||||
print(format_trace_step(step))
|
||||
|
||||
answer = next(s["content"] for s in trace if s["type"] == "answer")
|
||||
return {"question": question, "trace": trace, "answer": answer}
|
||||
|
||||
|
||||
# 独立运行示例
|
||||
def main():
|
||||
"""
|
||||
独立运行示例,演示基本用法
|
||||
"""
|
||||
# 设置 API key (确保已设置环境变量 MOONSHOT_API_KEY)
|
||||
agent = WebSearchAgent()
|
||||
|
||||
# 示例问题
|
||||
test_question = "请搜索 Moonshot AI Context Caching 技术,告诉我这是什么。"
|
||||
|
||||
print(f"问题: {test_question}")
|
||||
print("-" * 60)
|
||||
print("搜索中...")
|
||||
|
||||
# 获取答案
|
||||
answer = agent.search_and_answer(test_question)
|
||||
|
||||
print("\n答案:")
|
||||
print("-" * 60)
|
||||
print(answer)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
配置文件 - Kimi API 配置
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# Read the nearest .env, searching upward from the working directory, so a
|
||||
# single file at the repository root serves every chapter.
|
||||
load_dotenv()
|
||||
|
||||
|
||||
# Provider resolution lives in the shared agentbook package so every chapter
|
||||
# stays consistent; see agentbook/providers.py. The fallback keeps this
|
||||
# experiment runnable from a checkout where agentbook is not installed.
|
||||
try:
|
||||
from agentbook.providers import (
|
||||
SUPPORTED_PROVIDERS,
|
||||
map_model_to_openrouter,
|
||||
resolve_backend,
|
||||
resolve_llm_backend,
|
||||
)
|
||||
except ImportError: # pragma: no cover - exercised only without the package
|
||||
import sys as _sys
|
||||
|
||||
_sys.path.insert(
|
||||
0, str(__import__("pathlib").Path(__file__).resolve().parents[2])
|
||||
)
|
||||
from agentbook.providers import (
|
||||
SUPPORTED_PROVIDERS,
|
||||
map_model_to_openrouter,
|
||||
resolve_backend,
|
||||
resolve_llm_backend,
|
||||
)
|
||||
|
||||
|
||||
class Config:
|
||||
"""配置类"""
|
||||
|
||||
# Kimi API 配置
|
||||
MOONSHOT_API_KEY: str = os.getenv("MOONSHOT_API_KEY", "")
|
||||
# 向后兼容:如果没有 MOONSHOT_API_KEY,尝试使用 KIMI_API_KEY
|
||||
if not MOONSHOT_API_KEY:
|
||||
MOONSHOT_API_KEY = os.getenv("KIMI_API_KEY", "")
|
||||
|
||||
KIMI_BASE_URL: str = "https://api.moonshot.cn/v1"
|
||||
|
||||
# 模型配置
|
||||
DEFAULT_MODEL: str = "kimi-k3" # 使用最新的 Kimi K3 模型
|
||||
|
||||
# 搜索配置
|
||||
MAX_SEARCH_ITERATIONS: int = 5 # 最大搜索迭代次数(与 agent 默认值保持一致)
|
||||
SEARCH_TIMEOUT: float = float(os.getenv("SEARCH_TIMEOUT", "30"))
|
||||
|
||||
# 日志配置
|
||||
LOG_LEVEL: str = "INFO"
|
||||
LOG_FORMAT: str = "%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
|
||||
@classmethod
|
||||
def validate(cls) -> bool:
|
||||
"""
|
||||
验证配置是否有效
|
||||
|
||||
Returns:
|
||||
bool: 配置是否有效
|
||||
"""
|
||||
if not cls.MOONSHOT_API_KEY:
|
||||
print("错误: 未设置 MOONSHOT_API_KEY 环境变量")
|
||||
print("请设置环境变量: export MOONSHOT_API_KEY='your-api-key'")
|
||||
print("(或者使用旧的环境变量名: export KIMI_API_KEY='your-api-key')")
|
||||
return False
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def get_api_key(cls, api_key: Optional[str] = None) -> str:
|
||||
"""
|
||||
获取 API Key
|
||||
|
||||
Args:
|
||||
api_key: 可选的 API key,如果提供则使用,否则从环境变量获取
|
||||
|
||||
Returns:
|
||||
API key
|
||||
"""
|
||||
if api_key:
|
||||
return api_key
|
||||
return cls.MOONSHOT_API_KEY
|
||||
@@ -0,0 +1,16 @@
|
||||
# Kimi API 配置
|
||||
# 从 https://platform.moonshot.cn/ 获取您的 API Key
|
||||
MOONSHOT_API_KEY=your-api-key-here
|
||||
|
||||
# 可选配置
|
||||
# KIMI_BASE_URL=https://api.moonshot.cn/v1
|
||||
# DEFAULT_MODEL=kimi-k3
|
||||
# MAX_SEARCH_ITERATIONS=5
|
||||
# SEARCH_TIMEOUT=30
|
||||
|
||||
# 通用兜底:当 MOONSHOT_API_KEY 缺失时,若设置了 OPENROUTER_API_KEY,
|
||||
# 请求会自动改走 OpenRouter。注意:Kimi 内置 $web_search 工具在 OpenRouter
|
||||
# 上不可用,此兜底模式下模型仅凭自身知识作答(无实时联网搜索),
|
||||
# 且使用 OPENROUTER_MODEL(默认 openai/gpt-5.6-luna)。
|
||||
# OPENROUTER_API_KEY=your-openrouter-api-key
|
||||
# OPENROUTER_MODEL=openai/gpt-5.6-luna
|
||||
@@ -0,0 +1,324 @@
|
||||
"""
|
||||
高级示例 - 展示 Web Search Agent 的各种用法
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from typing import List, Dict, Any
|
||||
from agent import WebSearchAgent, is_failure_answer
|
||||
from config import Config
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AdvancedWebSearchAgent(WebSearchAgent):
|
||||
"""
|
||||
高级 Web Search Agent - 扩展功能
|
||||
"""
|
||||
|
||||
def batch_search(self, questions: List[str]) -> List[Dict[str, str]]:
|
||||
"""
|
||||
批量搜索多个问题
|
||||
|
||||
Args:
|
||||
questions: 问题列表
|
||||
|
||||
Returns:
|
||||
答案列表
|
||||
"""
|
||||
results = []
|
||||
for i, question in enumerate(questions, 1):
|
||||
logger.info(f"处理问题 {i}/{len(questions)}: {question}")
|
||||
try:
|
||||
answer = self.search_and_answer(question)
|
||||
# search_and_answer 内部已捕获异常并返回错误字符串(见 agent.py),
|
||||
# 因此下面的 except 通常不会触发。用统一的 is_failure_answer 判定状态,
|
||||
# 覆盖“出现错误 / 超过最大迭代次数 / 无法获取足够信息”所有失败兜底,
|
||||
# 避免把失败的搜索错误地标记为 success。
|
||||
status = "error" if is_failure_answer(answer) else "success"
|
||||
results.append({
|
||||
"question": question,
|
||||
"answer": answer,
|
||||
"status": status
|
||||
})
|
||||
except Exception as e:
|
||||
results.append({
|
||||
"question": question,
|
||||
"answer": str(e),
|
||||
"status": "error"
|
||||
})
|
||||
# 清空历史,避免上下文混淆
|
||||
self.clear_history()
|
||||
return results
|
||||
|
||||
def search_with_context(self, question: str, context: str) -> str:
|
||||
"""
|
||||
带上下文的搜索
|
||||
|
||||
Args:
|
||||
question: 用户问题
|
||||
context: 额外的上下文信息
|
||||
|
||||
Returns:
|
||||
答案
|
||||
"""
|
||||
# 构建带上下文的问题
|
||||
contextualized_question = f"""
|
||||
背景信息:{context}
|
||||
|
||||
基于上述背景,请回答以下问题:
|
||||
{question}
|
||||
"""
|
||||
return self.search_and_answer(contextualized_question)
|
||||
|
||||
def comparative_search(self, items: List[str], aspect: str) -> str:
|
||||
"""
|
||||
比较搜索 - 搜索并比较多个项目
|
||||
|
||||
Args:
|
||||
items: 要比较的项目列表
|
||||
aspect: 比较的方面
|
||||
|
||||
Returns:
|
||||
比较结果
|
||||
"""
|
||||
# 构建比较问题
|
||||
items_str = "、".join(items)
|
||||
question = f"请搜索并比较 {items_str} 在 {aspect} 方面的差异和优劣"
|
||||
|
||||
return self.search_and_answer(question)
|
||||
|
||||
def fact_check(self, statement: str) -> Dict[str, Any]:
|
||||
"""
|
||||
事实核查 - 验证陈述的真实性
|
||||
|
||||
Args:
|
||||
statement: 需要验证的陈述
|
||||
|
||||
Returns:
|
||||
验证结果
|
||||
"""
|
||||
question = f"""
|
||||
请验证以下陈述的真实性:
|
||||
"{statement}"
|
||||
|
||||
请严格按以下格式作答:
|
||||
- 第一行只输出判定结论,三选一:真 / 假 / 部分真实
|
||||
- 之后另起一行给出相关事实、证据与信息来源
|
||||
"""
|
||||
answer = self.search_and_answer(question)
|
||||
|
||||
# 解析判定:模型被要求首行只输出“真/假/部分真实”。
|
||||
# 按“部分真实 -> 假 -> 真”的优先级匹配,避免“真”字出现在
|
||||
# “部分真实/不真实”里而被误判为真(原实现 `"真" in answer[:100]` 的缺陷)。
|
||||
first_line = next((ln.strip() for ln in answer.splitlines() if ln.strip()), "")
|
||||
if "部分真实" in first_line or "部分正确" in first_line:
|
||||
is_true = False
|
||||
elif any(neg in first_line for neg in ("假", "不真实", "不属实", "不准确", "不正确", "错误")):
|
||||
is_true = False
|
||||
else:
|
||||
is_true = "真" in first_line or "属实" in first_line or "正确" in first_line
|
||||
return {
|
||||
"statement": statement,
|
||||
"is_true": is_true,
|
||||
"explanation": answer
|
||||
}
|
||||
|
||||
|
||||
def example_basic_search():
|
||||
"""基础搜索示例"""
|
||||
print("\n" + "="*60)
|
||||
print("📌 示例 1: 基础搜索")
|
||||
print("="*60)
|
||||
|
||||
agent = WebSearchAgent(Config.get_api_key())
|
||||
|
||||
questions = [
|
||||
"OpenAI 最新发布的 GPT 模型有什么特点?",
|
||||
"如何学习机器学习?推荐一些资源",
|
||||
]
|
||||
|
||||
for q in questions:
|
||||
print(f"\n问题: {q}")
|
||||
print("-"*40)
|
||||
answer = agent.search_and_answer(q)
|
||||
print(f"答案: {answer}")
|
||||
|
||||
|
||||
def example_batch_search():
|
||||
"""批量搜索示例"""
|
||||
print("\n" + "="*60)
|
||||
print("📌 示例 2: 批量搜索")
|
||||
print("="*60)
|
||||
|
||||
agent = AdvancedWebSearchAgent(Config.get_api_key())
|
||||
|
||||
questions = [
|
||||
"React 和 Vue 的主要区别是什么?",
|
||||
"Python 最适合做什么类型的项目?",
|
||||
"如何开始学习人工智能?",
|
||||
]
|
||||
|
||||
results = agent.batch_search(questions)
|
||||
|
||||
for result in results:
|
||||
print(f"\n问题: {result['question']}")
|
||||
print(f"状态: {result['status']}")
|
||||
print(f"答案: {result['answer'][:200]}...") # 只显示前200字符
|
||||
|
||||
|
||||
def example_contextual_search():
|
||||
"""带上下文的搜索示例"""
|
||||
print("\n" + "="*60)
|
||||
print("📌 示例 3: 带上下文的搜索")
|
||||
print("="*60)
|
||||
|
||||
agent = AdvancedWebSearchAgent(Config.get_api_key())
|
||||
|
||||
context = "我是一个刚开始学习编程的大学生,主要对 Web 开发感兴趣"
|
||||
question = "我应该先学习哪种编程语言?"
|
||||
|
||||
print(f"上下文: {context}")
|
||||
print(f"问题: {question}")
|
||||
print("-"*40)
|
||||
|
||||
answer = agent.search_with_context(question, context)
|
||||
print(f"答案: {answer}")
|
||||
|
||||
|
||||
def example_comparative_search():
|
||||
"""比较搜索示例"""
|
||||
print("\n" + "="*60)
|
||||
print("📌 示例 4: 比较搜索")
|
||||
print("="*60)
|
||||
|
||||
agent = AdvancedWebSearchAgent(Config.get_api_key())
|
||||
|
||||
# 比较不同的技术框架
|
||||
items = ["TensorFlow", "PyTorch", "JAX"]
|
||||
aspect = "性能和易用性"
|
||||
|
||||
print(f"比较项目: {', '.join(items)}")
|
||||
print(f"比较方面: {aspect}")
|
||||
print("-"*40)
|
||||
|
||||
result = agent.comparative_search(items, aspect)
|
||||
print(f"比较结果:\n{result}")
|
||||
|
||||
|
||||
def example_fact_check():
|
||||
"""事实核查示例"""
|
||||
print("\n" + "="*60)
|
||||
print("📌 示例 5: 事实核查")
|
||||
print("="*60)
|
||||
|
||||
agent = AdvancedWebSearchAgent(Config.get_api_key())
|
||||
|
||||
statements = [
|
||||
"Python 是世界上最流行的编程语言",
|
||||
"量子计算机已经可以破解所有现代加密算法",
|
||||
"GPT-4 有 1.76 万亿个参数",
|
||||
]
|
||||
|
||||
for statement in statements:
|
||||
print(f"\n陈述: {statement}")
|
||||
result = agent.fact_check(statement)
|
||||
print(f"真实性: {'✅ 真' if result['is_true'] else '❌ 假/存疑'}")
|
||||
print(f"解释: {result['explanation'][:200]}...")
|
||||
|
||||
|
||||
def example_research_assistant():
|
||||
"""研究助手示例 - 深度研究某个主题"""
|
||||
print("\n" + "="*60)
|
||||
print("📌 示例 6: 研究助手 - 深度研究")
|
||||
print("="*60)
|
||||
|
||||
agent = AdvancedWebSearchAgent(Config.get_api_key())
|
||||
|
||||
topic = "大语言模型的发展历程"
|
||||
|
||||
# 构建研究问题序列
|
||||
research_questions = [
|
||||
f"什么是{topic}?请提供详细定义",
|
||||
f"{topic}的关键里程碑和重要事件有哪些?",
|
||||
f"{topic}面临的主要挑战是什么?",
|
||||
f"{topic}的未来发展趋势如何?",
|
||||
]
|
||||
|
||||
print(f"研究主题: {topic}")
|
||||
print("="*60)
|
||||
|
||||
research_report = []
|
||||
for i, q in enumerate(research_questions, 1):
|
||||
print(f"\n研究问题 {i}: {q}")
|
||||
print("-"*40)
|
||||
answer = agent.search_and_answer(q)
|
||||
research_report.append({
|
||||
"section": i,
|
||||
"question": q,
|
||||
"findings": answer
|
||||
})
|
||||
print(f"发现: {answer[:300]}...")
|
||||
agent.clear_history() # 清空历史,确保每个问题独立
|
||||
|
||||
# 保存研究报告
|
||||
with open("research_report.json", "w", encoding="utf-8") as f:
|
||||
json.dump(research_report, f, ensure_ascii=False, indent=2)
|
||||
print(f"\n✅ 研究报告已保存到 research_report.json")
|
||||
|
||||
|
||||
def main():
|
||||
"""运行所有示例"""
|
||||
|
||||
if not Config.validate():
|
||||
print("请先设置 KIMI_API_KEY 环境变量")
|
||||
return
|
||||
|
||||
examples = [
|
||||
("基础搜索", example_basic_search),
|
||||
("批量搜索", example_batch_search),
|
||||
("带上下文搜索", example_contextual_search),
|
||||
("比较搜索", example_comparative_search),
|
||||
("事实核查", example_fact_check),
|
||||
("研究助手", example_research_assistant),
|
||||
]
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("🎯 Kimi Web Search Agent - 高级示例")
|
||||
print("="*60)
|
||||
print("\n选择要运行的示例:")
|
||||
|
||||
for i, (name, _) in enumerate(examples, 1):
|
||||
print(f"{i}. {name}")
|
||||
print(f"{len(examples) + 1}. 运行所有示例")
|
||||
print("0. 退出")
|
||||
|
||||
try:
|
||||
choice = input("\n请输入选项 (0-7): ").strip()
|
||||
choice = int(choice)
|
||||
|
||||
if choice == 0:
|
||||
print("退出程序")
|
||||
return
|
||||
elif 1 <= choice <= len(examples):
|
||||
examples[choice - 1][1]()
|
||||
elif choice == len(examples) + 1:
|
||||
for name, func in examples:
|
||||
try:
|
||||
func()
|
||||
except Exception as e:
|
||||
logger.error(f"运行 {name} 时出错: {str(e)}")
|
||||
else:
|
||||
print("无效的选项")
|
||||
except ValueError:
|
||||
print("请输入有效的数字")
|
||||
except KeyboardInterrupt:
|
||||
print("\n程序被中断")
|
||||
except Exception as e:
|
||||
logger.error(f"运行示例时出错: {str(e)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,222 @@
|
||||
"""
|
||||
主程序 - Web Search Agent 使用示例
|
||||
|
||||
演示第一章的 ReAct 循环(Reasoning + Acting):模型先思考,再调用 web_search
|
||||
行动,观察搜索结果后继续思考,直到综合出最终答案。运行时会逐步打印 ReAct 轨迹。
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import argparse
|
||||
import logging
|
||||
from typing import Optional
|
||||
from agent import WebSearchAgent, run_offline_demo
|
||||
from config import Config
|
||||
|
||||
# 设置日志
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, Config.LOG_LEVEL),
|
||||
format=Config.LOG_FORMAT
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _save_output(path: str, payload: dict):
|
||||
"""把问题、ReAct 轨迹和答案保存为 JSON 文件"""
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump(payload, f, ensure_ascii=False, indent=2)
|
||||
print(f"\n💾 结果已保存到: {path}")
|
||||
|
||||
|
||||
def run_interactive_mode(agent: WebSearchAgent, output: Optional[str] = None):
|
||||
"""
|
||||
交互式模式 - 持续与 Agent 对话
|
||||
|
||||
Args:
|
||||
agent: WebSearchAgent 实例
|
||||
output: 可选,保存每次问答轨迹的 JSON 文件路径
|
||||
"""
|
||||
print("\n" + "="*60)
|
||||
print("🤖 Kimi Web Search Agent - 交互模式")
|
||||
print("="*60)
|
||||
print("输入您的问题,Agent 将自动搜索并回答")
|
||||
print("输入 'quit' 或 'exit' 退出")
|
||||
print("输入 'clear' 清空对话历史")
|
||||
print("="*60 + "\n")
|
||||
|
||||
while True:
|
||||
try:
|
||||
# 获取用户输入
|
||||
user_input = input("您的问题: ").strip()
|
||||
|
||||
# 检查退出命令
|
||||
if user_input.lower() in ['quit', 'exit', 'q']:
|
||||
print("\n👋 再见!")
|
||||
break
|
||||
|
||||
# 检查清空命令
|
||||
if user_input.lower() == 'clear':
|
||||
agent.clear_history()
|
||||
print("✅ 对话历史已清空\n")
|
||||
continue
|
||||
|
||||
# 检查空输入
|
||||
if not user_input:
|
||||
print("❌ 请输入一个问题\n")
|
||||
continue
|
||||
|
||||
# 显示思考中
|
||||
print("\n🔍 Agent 正在搜索和思考(ReAct 轨迹如下)...\n")
|
||||
|
||||
# 获取答案(verbose=True 时轨迹已在 agent 内实时打印)
|
||||
answer = agent.search_and_answer(user_input, max_iterations=Config.MAX_SEARCH_ITERATIONS)
|
||||
|
||||
# 显示答案
|
||||
print("\n" + "="*60)
|
||||
print("📝 Agent 回答:")
|
||||
print("-"*60)
|
||||
print(answer)
|
||||
print("="*60 + "\n")
|
||||
|
||||
if output:
|
||||
_save_output(output, {"question": user_input,
|
||||
"trace": agent.get_trace(),
|
||||
"answer": answer,
|
||||
"api_turns": agent.get_api_turns(),
|
||||
"provider": "openrouter" if agent.using_openrouter else "moonshot",
|
||||
"model": agent.model,
|
||||
"base_url": agent.base_url})
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n\n👋 检测到中断,退出程序")
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"处理问题时出错: {str(e)}")
|
||||
print(f"\n❌ 出错了: {str(e)}\n")
|
||||
|
||||
|
||||
def run_single_question(agent: WebSearchAgent, question: str,
|
||||
max_iterations: int, output: Optional[str] = None):
|
||||
"""
|
||||
单个问题模式 - 回答一个问题后退出
|
||||
|
||||
Args:
|
||||
agent: WebSearchAgent 实例
|
||||
question: 要回答的问题
|
||||
max_iterations: 最大 ReAct 迭代次数
|
||||
output: 可选,保存轨迹的 JSON 文件路径
|
||||
"""
|
||||
print("\n" + "="*60)
|
||||
print("🤖 Kimi Web Search Agent")
|
||||
print("="*60)
|
||||
print(f"问题: {question}")
|
||||
print("-"*60)
|
||||
print("🔍 ReAct 轨迹(想 → 做 → 看):\n")
|
||||
|
||||
try:
|
||||
answer = agent.search_and_answer(question, max_iterations=max_iterations)
|
||||
print("\n📝 答案:")
|
||||
print("-"*60)
|
||||
print(answer)
|
||||
print("="*60 + "\n")
|
||||
|
||||
if output:
|
||||
_save_output(output, {"question": question,
|
||||
"trace": agent.get_trace(),
|
||||
"answer": answer,
|
||||
"api_turns": agent.get_api_turns(),
|
||||
"provider": "openrouter" if agent.using_openrouter else "moonshot",
|
||||
"model": agent.model,
|
||||
"base_url": agent.base_url})
|
||||
except Exception as e:
|
||||
logger.error(f"处理问题时出错: {str(e)}")
|
||||
print(f"\n❌ 出错了: {str(e)}\n")
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
"""构建命令行参数解析器(中文帮助)"""
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="main.py",
|
||||
description="Kimi Web Search Agent —— 演示 ReAct 循环(思考→行动→观察)的搜索 Agent。",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""示例:
|
||||
python main.py # 进入交互模式
|
||||
python main.py "2024 诺贝尔物理学奖得主是谁?" # 单次问答,打印 ReAct 轨迹
|
||||
python main.py --provider offline-demo # 离线演示 ReAct 循环(无需 API Key)
|
||||
python main.py "比特币现价" --max-steps 3 --output result.json
|
||||
""",
|
||||
)
|
||||
parser.add_argument("query", nargs="*",
|
||||
help="要提问的问题;省略则进入交互模式")
|
||||
parser.add_argument("--provider", choices=["kimi", "offline-demo"], default="kimi",
|
||||
help="搜索后端:kimi=调用 Kimi Formula web_search(需 API Key);"
|
||||
"offline-demo=离线回放示例轨迹(默认 kimi)")
|
||||
parser.add_argument("--model", default=Config.DEFAULT_MODEL,
|
||||
help=f"使用的模型名称(默认 {Config.DEFAULT_MODEL})")
|
||||
parser.add_argument("--max-steps", type=int, default=Config.MAX_SEARCH_ITERATIONS,
|
||||
help=f"最大 ReAct 迭代次数(默认 {Config.MAX_SEARCH_ITERATIONS})")
|
||||
parser.add_argument("--base-url", default=Config.KIMI_BASE_URL,
|
||||
help=f"API 基础 URL(默认 {Config.KIMI_BASE_URL})")
|
||||
parser.add_argument("--api-key", default=None,
|
||||
help="Kimi API Key(默认从 MOONSHOT_API_KEY / KIMI_API_KEY 环境变量读取)")
|
||||
parser.add_argument("--output", "-o", default=None,
|
||||
help="将问题、ReAct 轨迹和答案保存到指定 JSON 文件")
|
||||
parser.add_argument("--quiet", action="store_true",
|
||||
help="不实时打印 ReAct 轨迹(默认打印)")
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: Optional[list] = None):
|
||||
"""主函数:解析命令行参数并分发到相应模式"""
|
||||
parser = build_parser()
|
||||
args = parser.parse_args(argv)
|
||||
question = " ".join(args.query).strip()
|
||||
|
||||
# 离线演示模式:无需 API Key,回放示例轨迹展示 ReAct 循环
|
||||
if args.provider == "offline-demo":
|
||||
demo_question = question or "Moonshot AI 的 Context Caching 是什么技术?"
|
||||
print("\n" + "="*60)
|
||||
print("🧪 离线演示模式(示例轨迹,非真实搜索结果)")
|
||||
print("="*60)
|
||||
print(f"问题: {demo_question}")
|
||||
print("-"*60)
|
||||
print("🔍 ReAct 轨迹(想 → 做 → 看):\n")
|
||||
result = run_offline_demo(demo_question, verbose=not args.quiet)
|
||||
print("\n📝 答案:")
|
||||
print("-"*60)
|
||||
print(result["answer"])
|
||||
print("="*60 + "\n")
|
||||
if args.output:
|
||||
_save_output(args.output, result)
|
||||
return
|
||||
|
||||
# 在线模式:需要 API Key
|
||||
api_key = Config.get_api_key(args.api_key)
|
||||
if not api_key and not os.getenv("OPENROUTER_API_KEY"):
|
||||
Config.validate()
|
||||
print("提示:也可设置 OPENROUTER_API_KEY 作为通用兜底。")
|
||||
sys.exit(1)
|
||||
|
||||
# 创建 Agent
|
||||
try:
|
||||
agent = WebSearchAgent(
|
||||
api_key=api_key,
|
||||
base_url=args.base_url,
|
||||
model=args.model,
|
||||
verbose=not args.quiet,
|
||||
)
|
||||
logger.info("Agent 初始化成功")
|
||||
except Exception as e:
|
||||
logger.error(f"Agent 初始化失败: {str(e)}")
|
||||
sys.exit(1)
|
||||
|
||||
# 有问题则单次问答,否则进入交互模式
|
||||
if question:
|
||||
run_single_question(agent, question, args.max_steps, args.output)
|
||||
else:
|
||||
run_interactive_mode(agent, args.output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,4 @@
|
||||
[pytest]
|
||||
testpaths = tests
|
||||
pythonpath = .
|
||||
addopts = -ra --strict-markers
|
||||
Executable
+196
@@ -0,0 +1,196 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
快速开始脚本 - 一键体验 Kimi Web Search Agent
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from agent import WebSearchAgent
|
||||
from config import Config
|
||||
|
||||
# 彩色输出
|
||||
class Colors:
|
||||
HEADER = '\033[95m'
|
||||
BLUE = '\033[94m'
|
||||
CYAN = '\033[96m'
|
||||
GREEN = '\033[92m'
|
||||
WARNING = '\033[93m'
|
||||
FAIL = '\033[91m'
|
||||
END = '\033[0m'
|
||||
BOLD = '\033[1m'
|
||||
|
||||
|
||||
def print_colored(text, color):
|
||||
"""打印彩色文本"""
|
||||
print(f"{color}{text}{Colors.END}")
|
||||
|
||||
|
||||
def print_banner():
|
||||
"""打印欢迎横幅"""
|
||||
banner = """
|
||||
╔══════════════════════════════════════════════════════════╗
|
||||
║ 🤖 Kimi Web Search Agent - 快速体验 ║
|
||||
║ ║
|
||||
║ 基于 Kimi API 的智能搜索助手 ║
|
||||
║ 能够自动搜索网络信息并生成智能答案 ║
|
||||
╚══════════════════════════════════════════════════════════╝
|
||||
"""
|
||||
print_colored(banner, Colors.CYAN)
|
||||
|
||||
|
||||
def check_api_key():
|
||||
"""检查 API Key 配置"""
|
||||
api_key = os.getenv("MOONSHOT_API_KEY")
|
||||
if not api_key:
|
||||
# 向后兼容:尝试旧的环境变量名
|
||||
api_key = os.getenv("KIMI_API_KEY")
|
||||
|
||||
if not api_key:
|
||||
print_colored("\n⚠️ 未检测到 API Key", Colors.WARNING)
|
||||
print("\n请按以下步骤配置:")
|
||||
print("1. 访问 https://platform.moonshot.ai/ 获取 API Key")
|
||||
print("2. 设置环境变量:")
|
||||
print(" export MOONSHOT_API_KEY='your-api-key'")
|
||||
print(" (或使用: export KIMI_API_KEY='your-api-key')")
|
||||
print("\n或者直接输入 API Key (输入 'skip' 跳过):")
|
||||
|
||||
user_input = input("> ").strip()
|
||||
|
||||
if user_input.lower() == 'skip':
|
||||
return None
|
||||
elif user_input:
|
||||
return user_input
|
||||
else:
|
||||
return None
|
||||
|
||||
print_colored("✅ API Key 已配置", Colors.GREEN)
|
||||
return api_key
|
||||
|
||||
|
||||
def demo_search(agent):
|
||||
"""演示搜索功能"""
|
||||
print_colored("\n📝 演示搜索功能", Colors.HEADER)
|
||||
print("-" * 60)
|
||||
|
||||
demo_questions = [
|
||||
"OpenAI 最新发布了什么产品?",
|
||||
"2024年有哪些重要的AI突破?",
|
||||
"如何开始学习机器学习?",
|
||||
]
|
||||
|
||||
print("选择一个演示问题,或输入您自己的问题:")
|
||||
for i, q in enumerate(demo_questions, 1):
|
||||
print(f"{i}. {q}")
|
||||
print("0. 输入自定义问题")
|
||||
|
||||
choice = input("\n请选择 (0-3): ").strip()
|
||||
|
||||
try:
|
||||
choice = int(choice)
|
||||
if choice == 0:
|
||||
question = input("请输入您的问题: ").strip()
|
||||
if not question:
|
||||
print_colored("❌ 问题不能为空", Colors.FAIL)
|
||||
return
|
||||
elif 1 <= choice <= len(demo_questions):
|
||||
question = demo_questions[choice - 1]
|
||||
else:
|
||||
print_colored("❌ 无效的选择", Colors.FAIL)
|
||||
return
|
||||
except ValueError:
|
||||
print_colored("❌ 请输入数字", Colors.FAIL)
|
||||
return
|
||||
|
||||
print_colored(f"\n🔍 正在搜索: {question}", Colors.BLUE)
|
||||
print("请稍候,Agent 正在搜索和分析...")
|
||||
print("-" * 60)
|
||||
|
||||
try:
|
||||
answer = agent.search_and_answer(question)
|
||||
print_colored("\n📖 Agent 回答:", Colors.GREEN)
|
||||
print(answer)
|
||||
except Exception as e:
|
||||
print_colored(f"\n❌ 搜索失败: {str(e)}", Colors.FAIL)
|
||||
|
||||
|
||||
def interactive_mode(agent):
|
||||
"""交互模式"""
|
||||
print_colored("\n💬 进入交互模式", Colors.HEADER)
|
||||
print("您可以连续提问,输入 'quit' 退出")
|
||||
print("-" * 60)
|
||||
|
||||
while True:
|
||||
question = input("\n您的问题: ").strip()
|
||||
|
||||
if question.lower() in ['quit', 'exit', 'q']:
|
||||
print_colored("👋 感谢使用!", Colors.GREEN)
|
||||
break
|
||||
|
||||
if not question:
|
||||
continue
|
||||
|
||||
print_colored("🔍 搜索中...", Colors.BLUE)
|
||||
|
||||
try:
|
||||
answer = agent.search_and_answer(question)
|
||||
print_colored("\n📖 回答:", Colors.GREEN)
|
||||
print(answer)
|
||||
except Exception as e:
|
||||
print_colored(f"❌ 错误: {str(e)}", Colors.FAIL)
|
||||
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
print_banner()
|
||||
|
||||
# 检查 API Key
|
||||
api_key = check_api_key()
|
||||
if not api_key:
|
||||
print_colored("\n⚠️ 无法继续,需要配置 API Key", Colors.WARNING)
|
||||
sys.exit(1)
|
||||
|
||||
# 创建 Agent
|
||||
try:
|
||||
print_colored("\n🚀 初始化 Agent...", Colors.BLUE)
|
||||
agent = WebSearchAgent(api_key=api_key)
|
||||
print_colored("✅ Agent 已就绪", Colors.GREEN)
|
||||
except Exception as e:
|
||||
print_colored(f"❌ 初始化失败: {str(e)}", Colors.FAIL)
|
||||
sys.exit(1)
|
||||
|
||||
# 选择模式
|
||||
print("\n选择使用模式:")
|
||||
print("1. 演示搜索 (快速体验)")
|
||||
print("2. 交互模式 (连续对话)")
|
||||
print("3. 退出")
|
||||
|
||||
mode = input("\n请选择 (1-3): ").strip()
|
||||
|
||||
if mode == "1":
|
||||
demo_search(agent)
|
||||
# 询问是否继续
|
||||
cont = input("\n是否进入交互模式?(y/n): ").strip().lower()
|
||||
if cont == 'y':
|
||||
interactive_mode(agent)
|
||||
elif mode == "2":
|
||||
interactive_mode(agent)
|
||||
elif mode == "3":
|
||||
print_colored("👋 再见!", Colors.GREEN)
|
||||
else:
|
||||
print_colored("❌ 无效的选择", Colors.FAIL)
|
||||
|
||||
print_colored("\n感谢使用 Kimi Web Search Agent!", Colors.CYAN)
|
||||
print("更多功能请查看:")
|
||||
print("- README.md: 完整文档")
|
||||
print("- examples.py: 高级示例")
|
||||
print("- main.py: 主程序")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except KeyboardInterrupt:
|
||||
print_colored("\n\n👋 程序被中断", Colors.WARNING)
|
||||
except Exception as e:
|
||||
print_colored(f"\n❌ 发生错误: {str(e)}", Colors.FAIL)
|
||||
sys.exit(1)
|
||||
@@ -0,0 +1,16 @@
|
||||
# Kimi Web Search Agent 依赖
|
||||
|
||||
# 核心依赖
|
||||
openai>=1.12.0 # OpenAI Python 客户端,用于调用 Kimi API
|
||||
requests>=2.31.0 # Kimi Formula declarations and Fiber execution
|
||||
python-dotenv>=1.0.0 # 环境变量管理
|
||||
|
||||
# 可选依赖(用于高级功能)
|
||||
aiohttp>=3.9.0 # 异步 HTTP 请求
|
||||
rich>=13.7.0 # 美化终端输出
|
||||
tenacity>=8.2.3 # 重试机制
|
||||
|
||||
# 开发依赖
|
||||
pytest>=7.4.0 # 测试框架
|
||||
black>=23.0.0 # 代码格式化
|
||||
pylint>=3.0.0 # 代码检查
|
||||
@@ -0,0 +1,281 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run Experiment 1-2 through Kimi K3's official Formula web-search tool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from agent import WebSearchAgent, is_failure_answer
|
||||
|
||||
|
||||
QUESTION = """截至 2026 年 7 月 30 日,请核查东盟成员资格和印度尼西亚首都的最新状态。
|
||||
请自主完成研究:先搜索东盟成员国的官方来源,确认当前成员数量、成员名单及东帝汶正式入盟日期;
|
||||
检查第一轮证据还缺什么,然后至少再执行一次不同的后续搜索,核实雅加达与努山塔拉的当前法律地位以及总统令是否已生效。
|
||||
最后给出结构化结论、检索日期和可点击的权威来源链接。不要依赖记忆作答。"""
|
||||
|
||||
|
||||
def git_value(*args: str) -> str | None:
|
||||
try:
|
||||
return subprocess.check_output(
|
||||
["git", *args], text=True, stderr=subprocess.DEVNULL
|
||||
).strip()
|
||||
except (OSError, subprocess.CalledProcessError):
|
||||
return None
|
||||
|
||||
|
||||
FORMULA_URI = "moonshot/web-search:latest"
|
||||
|
||||
|
||||
def response_ids(turns: List[Dict[str, Any]]) -> List[str]:
|
||||
return [
|
||||
turn.get("response", {}).get("id")
|
||||
for turn in turns
|
||||
if turn.get("kind") == "chat_completion"
|
||||
if turn.get("response", {}).get("id")
|
||||
]
|
||||
|
||||
|
||||
def fiber_ids(turns: List[Dict[str, Any]]) -> List[str]:
|
||||
"""Return only real, succeeded Formula Fiber receipts."""
|
||||
return [
|
||||
turn.get("response", {}).get("id")
|
||||
for turn in turns
|
||||
if turn.get("kind") == "formula_fiber"
|
||||
and turn.get("http_status") == 200
|
||||
and turn.get("response", {}).get("status") == "succeeded"
|
||||
and turn.get("response", {}).get("id")
|
||||
]
|
||||
|
||||
|
||||
def has_web_search_declaration(tools: List[Dict[str, Any]]) -> bool:
|
||||
return any(
|
||||
tool.get("type") == "function"
|
||||
and tool.get("function", {}).get("name") == "web_search"
|
||||
and isinstance(tool.get("function", {}).get("parameters"), dict)
|
||||
for tool in tools
|
||||
)
|
||||
|
||||
|
||||
def usage(turns: List[Dict[str, Any]]) -> Dict[str, int]:
|
||||
prompt = completion = cached = reasoning = 0
|
||||
for turn in turns:
|
||||
if turn.get("kind") != "chat_completion":
|
||||
continue
|
||||
item = turn.get("response", {}).get("usage") or {}
|
||||
prompt += int(item.get("prompt_tokens") or 0)
|
||||
completion += int(item.get("completion_tokens") or 0)
|
||||
cached += int((item.get("prompt_tokens_details") or {}).get("cached_tokens") or 0)
|
||||
reasoning += int(
|
||||
(item.get("completion_tokens_details") or {}).get("reasoning_tokens") or 0
|
||||
)
|
||||
return {
|
||||
"prompt_tokens": prompt,
|
||||
"completion_tokens": completion,
|
||||
"total_tokens": prompt + completion,
|
||||
"cached_prompt_tokens": cached,
|
||||
"reasoning_tokens": reasoning,
|
||||
}
|
||||
|
||||
|
||||
def validate(payload: Dict[str, Any]) -> Dict[str, Any]:
|
||||
trace = payload["trace"]
|
||||
turns = payload["api_turns"]
|
||||
chat_turns = [t for t in turns if t.get("kind") == "chat_completion"]
|
||||
declaration_turns = [t for t in turns if t.get("kind") == "formula_tools"]
|
||||
fiber_turns = [t for t in turns if t.get("kind") == "formula_fiber"]
|
||||
ids = fiber_ids(turns)
|
||||
search_actions = [
|
||||
step
|
||||
for step in trace
|
||||
if step.get("type") == "action" and step.get("tool") == "web_search"
|
||||
]
|
||||
answer = payload["answer"]
|
||||
answer_lower = answer.lower()
|
||||
checks = {
|
||||
"direct_moonshot_api": payload["provider"] == "moonshot"
|
||||
and payload["base_url"].rstrip("/") == "https://api.moonshot.cn/v1",
|
||||
"exact_model": payload["model"] == "kimi-k3",
|
||||
"one_real_formula_declaration_fetch": len(declaration_turns) == 1
|
||||
and declaration_turns[0].get("formula_uri") == FORMULA_URI
|
||||
and declaration_turns[0].get("http_status") == 200
|
||||
and not declaration_turns[0].get("error"),
|
||||
"provider_formula_declares_standard_web_search": len(declaration_turns) == 1
|
||||
and has_web_search_declaration(
|
||||
declaration_turns[0].get("response", {}).get("tools", [])
|
||||
),
|
||||
"provider_response_each_chat_turn": len(response_ids(turns)) == len(chat_turns)
|
||||
and len(chat_turns) >= 3,
|
||||
"formula_tool_declared_each_chat_turn": bool(chat_turns)
|
||||
and all(
|
||||
has_web_search_declaration(turn.get("request", {}).get("tools", []))
|
||||
for turn in chat_turns
|
||||
),
|
||||
"all_fibers_succeeded": len(fiber_turns) >= 2
|
||||
and len(ids) == len(fiber_turns),
|
||||
"multiple_distinct_formula_fibers": len(ids) >= 2
|
||||
and len(set(ids)) >= 2,
|
||||
"fiber_requests_match_model_actions": len(fiber_turns) == len(search_actions)
|
||||
and all(
|
||||
turn.get("formula_uri") == FORMULA_URI
|
||||
and turn.get("request", {}).get("body", {}).get("name") == "web_search"
|
||||
and isinstance(
|
||||
turn.get("request", {}).get("body", {}).get("arguments"), str
|
||||
)
|
||||
for turn in fiber_turns
|
||||
),
|
||||
"sequential_search_rounds_observed": len(
|
||||
{step.get("iteration") for step in search_actions}
|
||||
)
|
||||
>= 2,
|
||||
"reasoning_observed": any(step.get("type") == "thought" for step in trace),
|
||||
"final_answer_observed": any(step.get("type") == "answer" for step in trace)
|
||||
and not is_failure_answer(answer),
|
||||
"source_links_in_answer": "http://" in answer or "https://" in answer,
|
||||
"official_sources_in_answer": "asean.org" in answer_lower
|
||||
and any(
|
||||
domain in answer_lower
|
||||
for domain in ("go.id", "polri.go.id", "mkri.id")
|
||||
),
|
||||
"current_eleven_member_fact": any(
|
||||
marker in answer_lower for marker in ("11", "十一")
|
||||
)
|
||||
and any(
|
||||
marker in answer_lower for marker in ("timor-leste", "东帝汶")
|
||||
),
|
||||
"timor_leste_admission_date": "2025" in answer_lower
|
||||
and any(marker in answer_lower for marker in ("10月26", "10 月 26", "10-26", "october 26")),
|
||||
"indonesia_capital_transition_explained": any(
|
||||
marker in answer_lower for marker in ("jakarta", "雅加达")
|
||||
)
|
||||
and any(
|
||||
marker in answer_lower for marker in ("nusantara", "努山塔拉")
|
||||
)
|
||||
and any(
|
||||
marker in answer_lower
|
||||
for marker in ("presidential decree", "presidential decision", "总统令")
|
||||
),
|
||||
"retrieval_date_reported": "2026" in answer_lower
|
||||
and any(marker in answer_lower for marker in ("7月30", "7 月 30", "2026-07-30")),
|
||||
}
|
||||
return {
|
||||
"checks": checks,
|
||||
"passed": all(checks.values()),
|
||||
"formula_uri": FORMULA_URI,
|
||||
"fiber_ids": ids,
|
||||
"usage": usage(turns),
|
||||
}
|
||||
|
||||
|
||||
def write_json(path: Path, value: Dict[str, Any]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def run_once(model: str, timeout: float) -> Dict[str, Any]:
|
||||
key = os.getenv("MOONSHOT_API_KEY") or os.getenv("KIMI_API_KEY")
|
||||
if not key:
|
||||
raise RuntimeError("MOONSHOT_API_KEY or KIMI_API_KEY is required")
|
||||
# The SDK retries transport failures; experiment-level retries below are
|
||||
# reserved for Moonshot's explicit transient engine-overload response.
|
||||
os.environ["SEARCH_TIMEOUT"] = str(timeout)
|
||||
agent = WebSearchAgent(api_key=key, model=model, verbose=True)
|
||||
answer = agent.search_and_answer(QUESTION, max_iterations=8)
|
||||
return {
|
||||
"provider": "openrouter" if agent.using_openrouter else "moonshot",
|
||||
"model": agent.model,
|
||||
"base_url": agent.base_url,
|
||||
"question": QUESTION,
|
||||
"answer": answer,
|
||||
"trace": agent.get_trace(),
|
||||
"api_turns": agent.get_api_turns(),
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--model", default="kimi-k3")
|
||||
parser.add_argument("--attempts", type=int, default=3)
|
||||
parser.add_argument("--timeout", type=float, default=180)
|
||||
parser.add_argument("--output-dir", type=Path)
|
||||
args = parser.parse_args()
|
||||
if args.model != "kimi-k3":
|
||||
parser.error("Experiment 1-2 requires the exact kimi-k3 model")
|
||||
|
||||
failures = []
|
||||
run = None
|
||||
for attempt in range(1, args.attempts + 1):
|
||||
candidate = run_once(args.model, args.timeout)
|
||||
validation = validate(candidate)
|
||||
if validation["passed"]:
|
||||
run = candidate
|
||||
break
|
||||
failures.append(
|
||||
{
|
||||
"attempt": attempt,
|
||||
"answer": candidate["answer"],
|
||||
"validation": validation,
|
||||
"api_turns": candidate["api_turns"],
|
||||
}
|
||||
)
|
||||
if attempt == args.attempts:
|
||||
run = candidate
|
||||
break
|
||||
# Kimi K3 can occasionally stop after a tool round, and Formula Fibers
|
||||
# can transiently overload. Both
|
||||
# are honest failed attempts; retry the whole independent run and keep
|
||||
# every failed API trace in the final evidence.
|
||||
time.sleep(2**attempt)
|
||||
assert run is not None
|
||||
|
||||
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
|
||||
output_dir = args.output_dir or Path("validation") / f"real_{stamp}"
|
||||
evidence = {
|
||||
"schema_version": "2.0",
|
||||
"experiment_id": "1-2",
|
||||
"evidence_mode": "real_api",
|
||||
"created_at": datetime.now(timezone.utc).isoformat(),
|
||||
"canonical_source": "book/chapter1.md#实验-1-2-kimi-k3-原生-agent-能力",
|
||||
"credential_source_env": "MOONSHOT_API_KEY"
|
||||
if os.getenv("MOONSHOT_API_KEY")
|
||||
else "KIMI_API_KEY",
|
||||
"credential_value_recorded": False,
|
||||
"host": {
|
||||
"platform": platform.platform(),
|
||||
"python": sys.version,
|
||||
"machine": platform.machine(),
|
||||
},
|
||||
"repository": {
|
||||
"commit": git_value("rev-parse", "HEAD"),
|
||||
"branch": git_value("branch", "--show-current"),
|
||||
"worktree_dirty": bool(git_value("status", "--porcelain")),
|
||||
},
|
||||
"transient_failed_attempts": failures,
|
||||
"run": run,
|
||||
}
|
||||
evidence["acceptance"] = validate(run)
|
||||
evidence_path = output_dir / "evidence.json"
|
||||
write_json(evidence_path, evidence)
|
||||
digest = hashlib.sha256(evidence_path.read_bytes()).hexdigest()
|
||||
(output_dir / "evidence.sha256").write_text(
|
||||
f"{digest} evidence.json\n", encoding="utf-8"
|
||||
)
|
||||
Path("validation").mkdir(exist_ok=True)
|
||||
shutil.copyfile(evidence_path, Path("validation/latest.json"))
|
||||
print(json.dumps(evidence["acceptance"], ensure_ascii=False, indent=2))
|
||||
print(f"Evidence: {evidence_path}")
|
||||
return 0 if evidence["acceptance"]["passed"] else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Shared pytest fixtures for the web search agent test suite."""
|
||||
|
||||
import json
|
||||
import socket
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
PROVIDER_ENV_VARS = (
|
||||
"MOONSHOT_API_KEY",
|
||||
"KIMI_API_KEY",
|
||||
"OPENROUTER_API_KEY",
|
||||
"OPENROUTER_BASE_URL",
|
||||
"OPENROUTER_MODEL",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def isolate_provider_environment(monkeypatch):
|
||||
"""Keep developer credentials and provider overrides out of every test."""
|
||||
for variable in PROVIDER_ENV_VARS:
|
||||
monkeypatch.delenv(variable, raising=False)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def block_external_network(monkeypatch):
|
||||
"""Fail fast if a unit test accidentally attempts a network connection."""
|
||||
|
||||
def deny_network(*args, **kwargs):
|
||||
raise AssertionError("Unit tests must not access the external network")
|
||||
|
||||
monkeypatch.setattr(socket, "create_connection", deny_network)
|
||||
monkeypatch.setattr(socket, "getaddrinfo", deny_network)
|
||||
monkeypatch.setattr(socket.socket, "connect", deny_network)
|
||||
monkeypatch.setattr(socket.socket, "connect_ex", deny_network)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_tool_call():
|
||||
"""Build a minimal SDK-shaped tool-call object for mocked model replies."""
|
||||
|
||||
def factory(
|
||||
*,
|
||||
name="web_search",
|
||||
arguments=None,
|
||||
call_id="call-1",
|
||||
):
|
||||
payload = arguments if arguments is not None else {"query": "example"}
|
||||
return SimpleNamespace(
|
||||
id=call_id,
|
||||
function=SimpleNamespace(
|
||||
name=name,
|
||||
arguments=json.dumps(payload, ensure_ascii=False),
|
||||
),
|
||||
)
|
||||
|
||||
return factory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def make_choice():
|
||||
"""Build a minimal SDK-shaped chat choice for deterministic Agent tests."""
|
||||
|
||||
def factory(
|
||||
*,
|
||||
finish_reason="stop",
|
||||
content="",
|
||||
reasoning_content=None,
|
||||
tool_calls=None,
|
||||
):
|
||||
return SimpleNamespace(
|
||||
finish_reason=finish_reason,
|
||||
message=SimpleNamespace(
|
||||
content=content,
|
||||
reasoning_content=reasoning_content,
|
||||
tool_calls=list(tool_calls or []),
|
||||
),
|
||||
)
|
||||
|
||||
return factory
|
||||
@@ -0,0 +1,284 @@
|
||||
"""Unit tests for ReAct formatting, tools, and the agent loop."""
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
from agent import WebSearchAgent, _reasoning_safe_temperature, format_trace_step
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, payload, status_code=200):
|
||||
self._payload = payload
|
||||
self.status_code = status_code
|
||||
self.text = ""
|
||||
|
||||
def json(self):
|
||||
return self._payload
|
||||
|
||||
def raise_for_status(self):
|
||||
if self.status_code >= 400:
|
||||
raise RuntimeError(f"HTTP {self.status_code}")
|
||||
|
||||
|
||||
def build_agent(*choices):
|
||||
"""Create an Agent without constructing a real OpenAI client."""
|
||||
instance = WebSearchAgent.__new__(WebSearchAgent)
|
||||
instance.verbose = False
|
||||
instance.using_openrouter = False
|
||||
instance.trace = []
|
||||
instance.conversation_history = []
|
||||
instance.api_turns = []
|
||||
instance._formula_tools = None
|
||||
instance._chat = Mock(side_effect=choices)
|
||||
instance._execute_formula = Mock(return_value="encrypted formula output")
|
||||
return instance
|
||||
|
||||
|
||||
def test_format_trace_step_formats_action_with_unicode_arguments():
|
||||
rendered = format_trace_step(
|
||||
{
|
||||
"iteration": 2,
|
||||
"type": "action",
|
||||
"tool": "web_search",
|
||||
"args": {"query": "서울 날씨"},
|
||||
}
|
||||
)
|
||||
|
||||
assert rendered == ('🔧 [2] 行动: 调用工具 web_search 参数={"query": "서울 날씨"}')
|
||||
|
||||
|
||||
def test_format_trace_step_truncates_long_content():
|
||||
rendered = format_trace_step(
|
||||
{"iteration": 1, "type": "thought", "content": "abcdef"},
|
||||
max_len=3,
|
||||
)
|
||||
|
||||
assert rendered == "💭 [1] 思考: abc…(省略 3 字)"
|
||||
|
||||
|
||||
def test_reasoning_models_force_supported_temperature():
|
||||
assert _reasoning_safe_temperature("kimi-k3", 0.2) == 1
|
||||
assert _reasoning_safe_temperature("openai/gpt-5.6-luna", 0.2) == 1
|
||||
assert _reasoning_safe_temperature("deepseek-chat", 0.2) == 0.2
|
||||
|
||||
|
||||
def test_tool_definition_is_available_for_moonshot_only():
|
||||
instance = WebSearchAgent.__new__(WebSearchAgent)
|
||||
instance.using_openrouter = False
|
||||
instance._formula_tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "web_search",
|
||||
"parameters": {"type": "object"},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
assert instance._get_tools() == instance._formula_tools
|
||||
|
||||
instance.using_openrouter = True
|
||||
assert instance._get_tools() == []
|
||||
|
||||
|
||||
def test_formula_declaration_is_fetched_and_recorded(monkeypatch):
|
||||
instance = WebSearchAgent.__new__(WebSearchAgent)
|
||||
instance.using_openrouter = False
|
||||
instance._formula_tools = None
|
||||
instance.base_url = "https://api.moonshot.cn/v1"
|
||||
instance.formula_uri = "moonshot/web-search:latest"
|
||||
instance._api_key = "not-recorded"
|
||||
instance._request_timeout = 12
|
||||
instance.api_turns = []
|
||||
tool = {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "web_search",
|
||||
"parameters": {"type": "object"},
|
||||
},
|
||||
}
|
||||
get = Mock(return_value=FakeResponse({"object": "list", "tools": [tool]}))
|
||||
monkeypatch.setattr("agent.requests.get", get)
|
||||
|
||||
assert instance._get_tools() == [tool]
|
||||
assert instance._get_tools() == [tool]
|
||||
assert get.call_count == 1
|
||||
assert instance.api_turns[0]["kind"] == "formula_tools"
|
||||
assert "Authorization" not in instance.api_turns[0]["request"]
|
||||
|
||||
|
||||
def test_formula_fiber_forwards_raw_arguments_and_records_receipt(monkeypatch):
|
||||
instance = WebSearchAgent.__new__(WebSearchAgent)
|
||||
instance.using_openrouter = False
|
||||
instance.base_url = "https://api.moonshot.cn/v1"
|
||||
instance.formula_uri = "moonshot/web-search:latest"
|
||||
instance._api_key = "not-recorded"
|
||||
instance._request_timeout = 12
|
||||
instance.api_turns = []
|
||||
raw = '{"query":"Moonshot K3"}'
|
||||
post = Mock(
|
||||
return_value=FakeResponse(
|
||||
{
|
||||
"id": "fiber-real",
|
||||
"status": "succeeded",
|
||||
"context": {"encrypted_output": "encrypted provider output"},
|
||||
}
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr("agent.requests.post", post)
|
||||
|
||||
assert instance._execute_formula("web_search", raw) == "encrypted provider output"
|
||||
assert post.call_args.kwargs["json"] == {
|
||||
"name": "web_search",
|
||||
"arguments": raw,
|
||||
}
|
||||
assert instance.api_turns[0]["response"]["id"] == "fiber-real"
|
||||
|
||||
|
||||
def test_agent_loop_records_tool_flow_and_final_answer(make_choice, make_tool_call):
|
||||
tool_call = make_tool_call(arguments={"query": "Moonshot caching"})
|
||||
tool_choice = make_choice(
|
||||
finish_reason="tool_calls",
|
||||
reasoning_content="공식 설명을 검색해야 한다.",
|
||||
tool_calls=[tool_call],
|
||||
)
|
||||
answer_choice = make_choice(content="Context Caching 설명입니다.")
|
||||
instance = build_agent(tool_choice, answer_choice)
|
||||
answer = instance.search_and_answer("Context Caching이 뭐야?")
|
||||
|
||||
assert answer == "Context Caching 설명입니다."
|
||||
assert [step["type"] for step in instance.get_trace()] == [
|
||||
"thought",
|
||||
"action",
|
||||
"observation",
|
||||
"answer",
|
||||
]
|
||||
instance._execute_formula.assert_called_once_with(
|
||||
"web_search", '{"query": "Moonshot caching"}'
|
||||
)
|
||||
assert instance._chat.call_count == 2
|
||||
assert instance.conversation_history[2] == {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call-1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "web_search",
|
||||
"arguments": '{"query": "Moonshot caching"}',
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
assert instance.conversation_history[3] == {
|
||||
"role": "tool",
|
||||
"tool_call_id": "call-1",
|
||||
"content": "encrypted formula output",
|
||||
}
|
||||
assert instance.conversation_history[-1] == {
|
||||
"role": "assistant",
|
||||
"content": answer,
|
||||
}
|
||||
|
||||
|
||||
def test_agent_loop_handles_multiple_tool_calls(make_choice, make_tool_call):
|
||||
first = make_tool_call(arguments={"query": "first"}, call_id="call-1")
|
||||
second = make_tool_call(arguments={"query": "second"}, call_id="call-2")
|
||||
instance = build_agent(
|
||||
make_choice(finish_reason="tool_calls", tool_calls=[first, second]),
|
||||
make_choice(content="combined answer"),
|
||||
)
|
||||
|
||||
assert instance.search_and_answer("compare") == "combined answer"
|
||||
assert [step["type"] for step in instance.get_trace()] == [
|
||||
"action",
|
||||
"observation",
|
||||
"action",
|
||||
"observation",
|
||||
"answer",
|
||||
]
|
||||
tool_messages = [
|
||||
message
|
||||
for message in instance.conversation_history
|
||||
if message["role"] == "tool"
|
||||
]
|
||||
assert [message["tool_call_id"] for message in tool_messages] == [
|
||||
"call-1",
|
||||
"call-2",
|
||||
]
|
||||
|
||||
|
||||
def test_agent_loop_stops_at_iteration_limit(make_choice, make_tool_call):
|
||||
instance = build_agent(
|
||||
make_choice(
|
||||
finish_reason="tool_calls",
|
||||
tool_calls=[make_tool_call()],
|
||||
)
|
||||
)
|
||||
|
||||
answer = instance.search_and_answer("keep searching", max_iterations=1)
|
||||
|
||||
assert answer == "抱歉,搜索过程超过了最大迭代次数,请稍后重试。"
|
||||
assert instance._chat.call_count == 1
|
||||
|
||||
|
||||
def test_agent_loop_returns_a_readable_error():
|
||||
instance = build_agent()
|
||||
instance._chat = Mock(side_effect=RuntimeError("provider unavailable"))
|
||||
|
||||
answer = instance.search_and_answer("question")
|
||||
|
||||
assert answer == "搜索过程中出现错误: provider unavailable"
|
||||
assert instance.get_trace() == []
|
||||
|
||||
|
||||
def test_agent_loop_marks_truncated_empty_answer(make_choice):
|
||||
"""finish_reason=length with empty content must not masquerade as
|
||||
the misleading 'couldn't get enough info' response."""
|
||||
instance = build_agent(make_choice(finish_reason="length", content=""))
|
||||
|
||||
answer = instance.search_and_answer("question")
|
||||
|
||||
assert "无法获取足够" not in answer
|
||||
assert "截断" in answer
|
||||
assert instance.get_trace()[-1]["type"] == "answer"
|
||||
|
||||
|
||||
def test_agent_loop_marks_truncated_partial_answer(make_choice):
|
||||
"""A partial answer cut off by max_tokens is returned WITH a truncation
|
||||
marker, never presented as a complete answer."""
|
||||
instance = build_agent(
|
||||
make_choice(finish_reason="length", content="部分答案,被截")
|
||||
)
|
||||
|
||||
answer = instance.search_and_answer("question")
|
||||
|
||||
assert answer.startswith("部分答案,被截")
|
||||
assert "截断" in answer
|
||||
# conversation_history retains the truncation marker (stores final, not the
|
||||
# bare partial), so get_conversation_history() doesn't lose the semantics.
|
||||
assert instance.conversation_history[-1]["role"] == "assistant"
|
||||
assert "截断" in instance.conversation_history[-1]["content"]
|
||||
|
||||
|
||||
def test_agent_loop_survives_malformed_tool_arguments_json(make_choice):
|
||||
"""Slightly invalid tool JSON must not abort the ReAct loop."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
bad_call = SimpleNamespace(
|
||||
id="call-bad",
|
||||
function=SimpleNamespace(
|
||||
name="web_search",
|
||||
arguments='{"query": "moonshot",}', # trailing comma
|
||||
),
|
||||
)
|
||||
tool_choice = make_choice(finish_reason="tool_calls", tool_calls=[bad_call])
|
||||
answer_choice = make_choice(content="recovered answer")
|
||||
instance = build_agent(tool_choice, answer_choice)
|
||||
answer = instance.search_and_answer("what is caching?")
|
||||
|
||||
assert answer == "recovered answer"
|
||||
instance._execute_formula.assert_called_once_with(
|
||||
"web_search", '{"query": "moonshot",}'
|
||||
)
|
||||
assert any(step["type"] == "action" for step in instance.get_trace())
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Unit tests for model mapping and provider selection."""
|
||||
|
||||
import pytest
|
||||
from config import map_model_to_openrouter, resolve_llm_backend
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("model", "expected"),
|
||||
[
|
||||
("openai/gpt-5.6-luna", "openai/gpt-5.6-luna"),
|
||||
("gpt-5.6-luna", "openai/gpt-5.6-luna"),
|
||||
("o3-mini", "openai/o3-mini"),
|
||||
("claude-sonnet-4.6", "anthropic/claude-sonnet-4.6"),
|
||||
("claude-haiku-4.5", "anthropic/claude-haiku-4.5"),
|
||||
("claude-opus-4.8", "anthropic/claude-opus-4.8"),
|
||||
("kimi-k3", "moonshotai/kimi-k2.6"),
|
||||
],
|
||||
)
|
||||
def test_map_model_to_openrouter(model, expected):
|
||||
assert map_model_to_openrouter(model) == expected
|
||||
|
||||
|
||||
def test_unknown_model_uses_configured_openrouter_default(monkeypatch):
|
||||
"""Substitution is opt-in, for callers that cannot send an unmapped id."""
|
||||
monkeypatch.setenv("OPENROUTER_MODEL", "vendor/fallback-model")
|
||||
|
||||
mapped = map_model_to_openrouter("unknown-model", substitute_unknown=True)
|
||||
assert mapped == "vendor/fallback-model"
|
||||
|
||||
|
||||
def test_unknown_model_is_kept_when_not_substituting(monkeypatch):
|
||||
"""Rerouting for credential reasons keeps the model the reader asked for."""
|
||||
monkeypatch.setenv("OPENROUTER_MODEL", "vendor/fallback-model")
|
||||
|
||||
assert map_model_to_openrouter("unknown-model") == "unknown-model"
|
||||
|
||||
|
||||
def test_primary_provider_is_preserved_when_its_key_exists():
|
||||
assert resolve_llm_backend(
|
||||
"moonshot-key", "https://moonshot.test/v1", "kimi-k3"
|
||||
) == (
|
||||
"moonshot-key",
|
||||
"https://moonshot.test/v1",
|
||||
"kimi-k3",
|
||||
False,
|
||||
)
|
||||
|
||||
|
||||
def test_openrouter_is_used_when_primary_key_is_missing(monkeypatch):
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "openrouter-key")
|
||||
monkeypatch.setenv("OPENROUTER_BASE_URL", "https://openrouter.test/v1")
|
||||
|
||||
assert resolve_llm_backend(None, "https://moonshot.test/v1", "kimi-k3") == (
|
||||
"openrouter-key",
|
||||
"https://openrouter.test/v1",
|
||||
"moonshotai/kimi-k2.6",
|
||||
True,
|
||||
)
|
||||
|
||||
|
||||
def test_gpt5_prefers_openrouter_when_both_keys_exist(monkeypatch):
|
||||
monkeypatch.setenv("OPENROUTER_API_KEY", "openrouter-key")
|
||||
|
||||
resolved = resolve_llm_backend(
|
||||
"primary-key", "https://primary.test/v1", "gpt-5.6-luna"
|
||||
)
|
||||
|
||||
assert resolved == (
|
||||
"openrouter-key",
|
||||
"https://openrouter.ai/api/v1",
|
||||
"openai/gpt-5.6-luna",
|
||||
True,
|
||||
)
|
||||
|
||||
|
||||
def test_provider_resolution_requires_a_key():
|
||||
with pytest.raises(ValueError, match="No API key found"):
|
||||
resolve_llm_backend(None, "https://moonshot.test/v1", "kimi-k3")
|
||||
@@ -0,0 +1,43 @@
|
||||
"""Unit tests for AdvancedWebSearchAgent helpers and failure classification."""
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
from agent import (
|
||||
MAX_ITERATIONS_MESSAGE,
|
||||
NO_INFO_MESSAGE,
|
||||
SEARCH_ERROR_PREFIX,
|
||||
is_failure_answer,
|
||||
)
|
||||
from examples import AdvancedWebSearchAgent
|
||||
|
||||
|
||||
def build_advanced(answers):
|
||||
"""AdvancedWebSearchAgent without a real OpenAI client; search_and_answer mocked."""
|
||||
instance = AdvancedWebSearchAgent.__new__(AdvancedWebSearchAgent)
|
||||
instance.search_and_answer = Mock(side_effect=answers)
|
||||
instance.clear_history = Mock()
|
||||
return instance
|
||||
|
||||
|
||||
def test_is_failure_answer_covers_every_failure_fallback():
|
||||
assert is_failure_answer(f"{SEARCH_ERROR_PREFIX}: boom") is True
|
||||
assert is_failure_answer(MAX_ITERATIONS_MESSAGE) is True
|
||||
assert is_failure_answer(NO_INFO_MESSAGE) is True
|
||||
assert is_failure_answer("北京今天多云。") is False
|
||||
|
||||
|
||||
def test_batch_search_marks_all_failure_fallbacks_as_error():
|
||||
"""search_and_answer never raises; every failure fallback prefix must map to
|
||||
status='error', not just the '搜索过程中出现错误' one."""
|
||||
answers = [
|
||||
"正常答案",
|
||||
f"{SEARCH_ERROR_PREFIX}: network down",
|
||||
MAX_ITERATIONS_MESSAGE,
|
||||
NO_INFO_MESSAGE,
|
||||
]
|
||||
instance = build_advanced(answers)
|
||||
|
||||
results = instance.batch_search(["q1", "q2", "q3", "q4"])
|
||||
|
||||
assert [r["status"] for r in results] == ["success", "error", "error", "error"]
|
||||
assert instance.clear_history.call_count == 4
|
||||
@@ -0,0 +1,185 @@
|
||||
from run_experiment_1_2 import fiber_ids, validate
|
||||
|
||||
|
||||
def formula_tool():
|
||||
return {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "web_search",
|
||||
"description": "Search the web",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"query": {"type": "string"}},
|
||||
"required": ["query"],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_fiber_ids_only_accept_real_succeeded_receipts():
|
||||
turns = [
|
||||
{
|
||||
"kind": "formula_fiber",
|
||||
"http_status": 200,
|
||||
"response": {"id": "fiber-one", "status": "succeeded"},
|
||||
},
|
||||
{
|
||||
"kind": "formula_fiber",
|
||||
"http_status": 200,
|
||||
"response": {"id": "fiber-failed", "status": "failed"},
|
||||
},
|
||||
{
|
||||
"kind": "chat_completion",
|
||||
"http_status": 200,
|
||||
"response": {"id": "chat-one"},
|
||||
},
|
||||
]
|
||||
assert fiber_ids(turns) == ["fiber-one"]
|
||||
|
||||
|
||||
def test_acceptance_requires_distinct_sequential_formula_fibers_and_links():
|
||||
tool = formula_tool()
|
||||
turns = [
|
||||
{
|
||||
"kind": "formula_tools",
|
||||
"formula_uri": "moonshot/web-search:latest",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "https://api.moonshot.cn/v1/formulas/moonshot/web-search:latest/tools",
|
||||
},
|
||||
"http_status": 200,
|
||||
"response": {"tools": [tool]},
|
||||
},
|
||||
{
|
||||
"kind": "chat_completion",
|
||||
"request": {"tools": [tool]},
|
||||
"response": {"id": "chat-1", "usage": {}},
|
||||
},
|
||||
{
|
||||
"kind": "formula_fiber",
|
||||
"formula_uri": "moonshot/web-search:latest",
|
||||
"request": {
|
||||
"body": {
|
||||
"name": "web_search",
|
||||
"arguments": '{"query":"ASEAN capitals"}',
|
||||
}
|
||||
},
|
||||
"http_status": 200,
|
||||
"response": {"id": "fiber-one", "status": "succeeded"},
|
||||
},
|
||||
{
|
||||
"kind": "chat_completion",
|
||||
"request": {"tools": [tool]},
|
||||
"response": {"id": "chat-2", "usage": {}},
|
||||
},
|
||||
{
|
||||
"kind": "formula_fiber",
|
||||
"formula_uri": "moonshot/web-search:latest",
|
||||
"request": {
|
||||
"body": {
|
||||
"name": "web_search",
|
||||
"arguments": '{"query":"ASEAN capital coordinates"}',
|
||||
}
|
||||
},
|
||||
"http_status": 200,
|
||||
"response": {"id": "fiber-two", "status": "succeeded"},
|
||||
},
|
||||
{
|
||||
"kind": "chat_completion",
|
||||
"request": {"tools": [tool]},
|
||||
"response": {"id": "chat-3", "usage": {}},
|
||||
},
|
||||
]
|
||||
payload = {
|
||||
"provider": "moonshot",
|
||||
"base_url": "https://api.moonshot.cn/v1",
|
||||
"model": "kimi-k3",
|
||||
"answer": (
|
||||
"检索日期 2026-07-30:东盟现有 11(十一)个成员,"
|
||||
"Timor-Leste(东帝汶)于 2025-10-26 加入。"
|
||||
"雅加达 Jakarta 在总统令生效前仍是首都,Nusantara 为迁都目标。"
|
||||
"https://asean.org/example https://inp.polri.go.id/example"
|
||||
),
|
||||
"trace": [
|
||||
{
|
||||
"iteration": 1,
|
||||
"type": "action",
|
||||
"tool": "web_search",
|
||||
"args": {"query": "ASEAN capitals"},
|
||||
},
|
||||
{"iteration": 1, "type": "thought"},
|
||||
{
|
||||
"iteration": 2,
|
||||
"type": "action",
|
||||
"tool": "web_search",
|
||||
"args": {"query": "ASEAN capital coordinates"},
|
||||
},
|
||||
{"iteration": 3, "type": "answer"},
|
||||
],
|
||||
"api_turns": turns,
|
||||
}
|
||||
assert validate(payload)["passed"] is True
|
||||
|
||||
|
||||
def test_acceptance_rejects_two_fibers_from_one_search_round():
|
||||
tool = formula_tool()
|
||||
payload = {
|
||||
"provider": "moonshot",
|
||||
"base_url": "https://api.moonshot.cn/v1",
|
||||
"model": "kimi-k3",
|
||||
"answer": (
|
||||
"2026-07-30:11 个成员,东帝汶 Timor-Leste 于 2025-10-26 加入。"
|
||||
"雅加达 Jakarta 在总统令前仍是首都,Nusantara 努山塔拉待迁都。"
|
||||
"https://asean.org/a https://inp.polri.go.id/b"
|
||||
),
|
||||
"trace": [
|
||||
{"iteration": 1, "type": "thought"},
|
||||
{
|
||||
"iteration": 1,
|
||||
"type": "action",
|
||||
"tool": "web_search",
|
||||
"args": {"query": "one"},
|
||||
},
|
||||
{
|
||||
"iteration": 1,
|
||||
"type": "action",
|
||||
"tool": "web_search",
|
||||
"args": {"query": "two"},
|
||||
},
|
||||
{"iteration": 2, "type": "answer"},
|
||||
],
|
||||
"api_turns": [
|
||||
{
|
||||
"kind": "formula_tools",
|
||||
"formula_uri": "moonshot/web-search:latest",
|
||||
"http_status": 200,
|
||||
"response": {"tools": [tool]},
|
||||
},
|
||||
*[
|
||||
{
|
||||
"kind": "chat_completion",
|
||||
"request": {"tools": [tool]},
|
||||
"response": {"id": f"chat-{i}", "usage": {}},
|
||||
}
|
||||
for i in range(3)
|
||||
],
|
||||
*[
|
||||
{
|
||||
"kind": "formula_fiber",
|
||||
"formula_uri": "moonshot/web-search:latest",
|
||||
"request": {
|
||||
"body": {
|
||||
"name": "web_search",
|
||||
"arguments": f'{{"query":"{i}"}}',
|
||||
}
|
||||
},
|
||||
"http_status": 200,
|
||||
"response": {"id": f"fiber-{i}", "status": "succeeded"},
|
||||
}
|
||||
for i in range(2)
|
||||
],
|
||||
],
|
||||
}
|
||||
result = validate(payload)
|
||||
assert result["checks"]["sequential_search_rounds_observed"] is False
|
||||
assert result["passed"] is False
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Tests for CLI parsing, offline dispatch, and JSON output."""
|
||||
|
||||
import json
|
||||
|
||||
import main as cli
|
||||
from config import Config
|
||||
|
||||
|
||||
def test_parser_defaults_to_interactive_kimi_mode():
|
||||
args = cli.build_parser().parse_args([])
|
||||
|
||||
assert args.query == []
|
||||
assert args.provider == "kimi"
|
||||
assert args.model == Config.DEFAULT_MODEL
|
||||
assert args.max_steps == Config.MAX_SEARCH_ITERATIONS
|
||||
assert args.base_url == Config.KIMI_BASE_URL
|
||||
assert args.output is None
|
||||
assert args.quiet is False
|
||||
|
||||
|
||||
def test_parser_accepts_cli_overrides():
|
||||
args = cli.build_parser().parse_args(
|
||||
[
|
||||
"first",
|
||||
"question",
|
||||
"--provider",
|
||||
"offline-demo",
|
||||
"--model",
|
||||
"custom-model",
|
||||
"--max-steps",
|
||||
"3",
|
||||
"--base-url",
|
||||
"https://provider.test/v1",
|
||||
"--api-key",
|
||||
"explicit-key",
|
||||
"--output",
|
||||
"result.json",
|
||||
"--quiet",
|
||||
]
|
||||
)
|
||||
|
||||
assert args.query == ["first", "question"]
|
||||
assert args.provider == "offline-demo"
|
||||
assert args.model == "custom-model"
|
||||
assert args.max_steps == 3
|
||||
assert args.base_url == "https://provider.test/v1"
|
||||
assert args.api_key == "explicit-key"
|
||||
assert args.output == "result.json"
|
||||
assert args.quiet is True
|
||||
|
||||
|
||||
def test_offline_cli_writes_utf8_json_without_api_credentials(tmp_path, capsys):
|
||||
output_path = tmp_path / "offline-result.json"
|
||||
|
||||
cli.main(
|
||||
[
|
||||
"한국어",
|
||||
"질문",
|
||||
"--provider",
|
||||
"offline-demo",
|
||||
"--quiet",
|
||||
"--output",
|
||||
str(output_path),
|
||||
]
|
||||
)
|
||||
|
||||
payload = json.loads(output_path.read_text(encoding="utf-8"))
|
||||
output = capsys.readouterr().out
|
||||
assert payload["question"] == "한국어 질문"
|
||||
assert set(payload) == {"question", "trace", "answer"}
|
||||
assert payload["trace"][-1]["type"] == "answer"
|
||||
assert payload["answer"] == payload["trace"][-1]["content"]
|
||||
assert "💭" not in output
|
||||
assert "结果已保存到" in output
|
||||
|
||||
|
||||
def test_offline_cli_uses_default_question_when_query_is_omitted(capsys):
|
||||
cli.main(["--provider", "offline-demo", "--quiet"])
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert "Moonshot AI 的 Context Caching 是什么技术?" in output
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Tests for the deterministic offline ReAct demonstration."""
|
||||
|
||||
from agent import run_offline_demo
|
||||
|
||||
|
||||
def test_offline_demo_returns_a_complete_deterministic_trace():
|
||||
result = run_offline_demo("캐싱이 뭐야?", verbose=False)
|
||||
|
||||
assert result["question"] == "캐싱이 뭐야?"
|
||||
assert [step["type"] for step in result["trace"]] == [
|
||||
"thought",
|
||||
"action",
|
||||
"observation",
|
||||
"thought",
|
||||
"action",
|
||||
"observation",
|
||||
"answer",
|
||||
]
|
||||
assert result["answer"] == result["trace"][-1]["content"]
|
||||
assert "离线示例轨迹" in result["answer"]
|
||||
|
||||
|
||||
def test_offline_demo_verbose_mode_prints_each_trace_step(capsys):
|
||||
result = run_offline_demo("question", verbose=True)
|
||||
|
||||
output = capsys.readouterr().out
|
||||
assert output.count("💭") == 2
|
||||
assert output.count("🔧") == 2
|
||||
assert output.count("👀") == 2
|
||||
assert output.count("✅") == 1
|
||||
assert result["answer"] in output
|
||||
|
||||
|
||||
def test_offline_demo_quiet_mode_prints_nothing(capsys):
|
||||
run_offline_demo("question", verbose=False)
|
||||
|
||||
assert capsys.readouterr().out == ""
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,408 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"experiment_id": "1-2",
|
||||
"evidence_mode": "real_api",
|
||||
"created_at": "2026-07-29T15:44:42.988928+00:00",
|
||||
"canonical_source": "book/chapter1.md#实验-1-2-kimi-k3-原生-agent-能力",
|
||||
"credential_source_env": "MOONSHOT_API_KEY",
|
||||
"credential_value_recorded": false,
|
||||
"host": {
|
||||
"platform": "macOS-26.3-arm64-arm-64bit",
|
||||
"python": "3.11.4 (main, Jul 5 2023, 08:40:20) [Clang 14.0.6 ]",
|
||||
"machine": "arm64"
|
||||
},
|
||||
"repository": {
|
||||
"commit": "4a7f37cf278bd15948c409f14533017c4c7fbc29",
|
||||
"branch": "main",
|
||||
"worktree_dirty": true
|
||||
},
|
||||
"transient_failed_attempts": [
|
||||
{
|
||||
"attempt": 1,
|
||||
"answer": "抱歉,我无法获取足够的信息来回答您的问题。",
|
||||
"validation": {
|
||||
"checks": {
|
||||
"direct_moonshot_api": true,
|
||||
"exact_model": true,
|
||||
"provider_response_each_turn": false,
|
||||
"hosted_search_tool_declared_each_search_turn": true,
|
||||
"multiple_hosted_searches": false,
|
||||
"reasoning_observed": false,
|
||||
"final_answer_observed": false,
|
||||
"source_links_in_answer": false,
|
||||
"distance_result_in_answer": false
|
||||
},
|
||||
"passed": false,
|
||||
"search_ids": [
|
||||
"bcfe63c76a6a1fe6680f160001863f8f"
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 656,
|
||||
"completion_tokens": 31,
|
||||
"total_tokens": 687,
|
||||
"cached_prompt_tokens": 0,
|
||||
"reasoning_tokens": 15
|
||||
}
|
||||
},
|
||||
"api_turns": [
|
||||
{
|
||||
"request": {
|
||||
"model": "kimi-k3",
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "你是 Kimi,一个智能搜索助手。\n\n请按照以下步骤处理:\n1. 分析用户问题,识别关键信息需求\n2. 使用 $web_search 工具搜索相关信息\n3. 如果需要更多信息,可以多次调用搜索工具\n4. 综合所有信息,生成准确、全面的答案\n\n注意:\n- 搜索时使用精准的关键词\n- 优先获取最新、最权威的信息\n- 答案要结构清晰,有理有据\n"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "截至 2026 年 7 月,东盟 10 个成员国的首都中,地理距离最近的一对是哪两个?\n请自主完成深度研究:先搜索成员国及官方首都的权威来源;检查还缺哪些坐标证据后,\n至少再执行一次不同的后续搜索来补齐可靠坐标;最后计算全部首都两两之间的大圆距离,\n给出最近一对、距离、计算方法、检索日期,并附可点击的来源链接。不要依赖记忆作答。"
|
||||
}
|
||||
],
|
||||
"temperature": 1,
|
||||
"max_tokens": 4096,
|
||||
"tools": [
|
||||
{
|
||||
"type": "builtin_function",
|
||||
"function": {
|
||||
"name": "$web_search"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"response": {
|
||||
"id": "chatcmpl-6a6a1fe625464c0001d53f11",
|
||||
"choices": [
|
||||
{
|
||||
"finish_reason": "tool_calls",
|
||||
"index": 0,
|
||||
"logprobs": null,
|
||||
"message": {
|
||||
"content": "",
|
||||
"refusal": null,
|
||||
"role": "assistant",
|
||||
"annotations": null,
|
||||
"audio": null,
|
||||
"function_call": null,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "t-web_search-6a6a1fe62546",
|
||||
"function": {
|
||||
"arguments": "{\"search_result\":{\"search_id\":\"bcfe63c76a6a1fe6680f160001863f8f\"},\"usage\":{\"total_tokens\":3120}}",
|
||||
"name": "$web_search"
|
||||
},
|
||||
"type": "builtin_function"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"created": 1785339878,
|
||||
"model": "kimi-k3",
|
||||
"object": "chat.completion",
|
||||
"service_tier": null,
|
||||
"system_fingerprint": null,
|
||||
"usage": {
|
||||
"completion_tokens": 1,
|
||||
"prompt_tokens": 203,
|
||||
"total_tokens": 204,
|
||||
"completion_tokens_details": null,
|
||||
"prompt_tokens_details": null
|
||||
}
|
||||
},
|
||||
"elapsed_seconds": 3.856849
|
||||
},
|
||||
{
|
||||
"request": {
|
||||
"model": "kimi-k3",
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "你是 Kimi,一个智能搜索助手。\n\n请按照以下步骤处理:\n1. 分析用户问题,识别关键信息需求\n2. 使用 $web_search 工具搜索相关信息\n3. 如果需要更多信息,可以多次调用搜索工具\n4. 综合所有信息,生成准确、全面的答案\n\n注意:\n- 搜索时使用精准的关键词\n- 优先获取最新、最权威的信息\n- 答案要结构清晰,有理有据\n"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "截至 2026 年 7 月,东盟 10 个成员国的首都中,地理距离最近的一对是哪两个?\n请自主完成深度研究:先搜索成员国及官方首都的权威来源;检查还缺哪些坐标证据后,\n至少再执行一次不同的后续搜索来补齐可靠坐标;最后计算全部首都两两之间的大圆距离,\n给出最近一对、距离、计算方法、检索日期,并附可点击的来源链接。不要依赖记忆作答。"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "t-web_search-6a6a1fe62546",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "$web_search",
|
||||
"arguments": "{\"search_result\":{\"search_id\":\"bcfe63c76a6a1fe6680f160001863f8f\"},\"usage\":{\"total_tokens\":3120}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "t-web_search-6a6a1fe62546",
|
||||
"name": "$web_search",
|
||||
"content": "{\"search_result\": {\"search_id\": \"bcfe63c76a6a1fe6680f160001863f8f\"}, \"usage\": {\"total_tokens\": 3120}}"
|
||||
}
|
||||
],
|
||||
"temperature": 1,
|
||||
"max_tokens": 4096,
|
||||
"tools": [
|
||||
{
|
||||
"type": "builtin_function",
|
||||
"function": {
|
||||
"name": "$web_search"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"response": {
|
||||
"id": "chatcmpl-6a6a1fe6f1ac6b519f46c5af",
|
||||
"choices": [
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"index": 0,
|
||||
"logprobs": null,
|
||||
"message": {
|
||||
"content": "",
|
||||
"refusal": null,
|
||||
"role": "assistant",
|
||||
"annotations": null,
|
||||
"audio": null,
|
||||
"function_call": null,
|
||||
"tool_calls": null,
|
||||
"reasoning_content": "The search returned no results. Let me try a different search query."
|
||||
}
|
||||
}
|
||||
],
|
||||
"created": 1785339881,
|
||||
"model": "kimi-k3",
|
||||
"object": "chat.completion",
|
||||
"service_tier": null,
|
||||
"system_fingerprint": null,
|
||||
"usage": {
|
||||
"completion_tokens": 30,
|
||||
"prompt_tokens": 453,
|
||||
"total_tokens": 483,
|
||||
"completion_tokens_details": {
|
||||
"accepted_prediction_tokens": null,
|
||||
"audio_tokens": null,
|
||||
"reasoning_tokens": 15,
|
||||
"rejected_prediction_tokens": null
|
||||
},
|
||||
"prompt_tokens_details": null
|
||||
}
|
||||
},
|
||||
"elapsed_seconds": 4.567783
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"run": {
|
||||
"provider": "moonshot",
|
||||
"model": "kimi-k3",
|
||||
"base_url": "https://api.moonshot.cn/v1",
|
||||
"question": "截至 2026 年 7 月,东盟 10 个成员国的首都中,地理距离最近的一对是哪两个?\n请自主完成深度研究:先搜索成员国及官方首都的权威来源;检查还缺哪些坐标证据后,\n至少再执行一次不同的后续搜索来补齐可靠坐标;最后计算全部首都两两之间的大圆距离,\n给出最近一对、距离、计算方法、检索日期,并附可点击的来源链接。不要依赖记忆作答。",
|
||||
"answer": "抱歉,我无法获取足够的信息来回答您的问题。",
|
||||
"trace": [
|
||||
{
|
||||
"iteration": 1,
|
||||
"type": "action",
|
||||
"tool": "$web_search",
|
||||
"args": {
|
||||
"search_result": {
|
||||
"search_id": "bcfe63c76a6a1fe6680f160001863f8f"
|
||||
},
|
||||
"usage": {
|
||||
"total_tokens": 3120
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"iteration": 1,
|
||||
"type": "observation",
|
||||
"tool": "$web_search",
|
||||
"content": "{\"search_result\": {\"search_id\": \"bcfe63c76a6a1fe6680f160001863f8f\"}, \"usage\": {\"total_tokens\": 3120}}"
|
||||
},
|
||||
{
|
||||
"iteration": 2,
|
||||
"type": "thought",
|
||||
"content": "The search returned no results. Let me try a different search query."
|
||||
}
|
||||
],
|
||||
"api_turns": [
|
||||
{
|
||||
"request": {
|
||||
"model": "kimi-k3",
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "你是 Kimi,一个智能搜索助手。\n\n请按照以下步骤处理:\n1. 分析用户问题,识别关键信息需求\n2. 使用 $web_search 工具搜索相关信息\n3. 如果需要更多信息,可以多次调用搜索工具\n4. 综合所有信息,生成准确、全面的答案\n\n注意:\n- 搜索时使用精准的关键词\n- 优先获取最新、最权威的信息\n- 答案要结构清晰,有理有据\n"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "截至 2026 年 7 月,东盟 10 个成员国的首都中,地理距离最近的一对是哪两个?\n请自主完成深度研究:先搜索成员国及官方首都的权威来源;检查还缺哪些坐标证据后,\n至少再执行一次不同的后续搜索来补齐可靠坐标;最后计算全部首都两两之间的大圆距离,\n给出最近一对、距离、计算方法、检索日期,并附可点击的来源链接。不要依赖记忆作答。"
|
||||
}
|
||||
],
|
||||
"temperature": 1,
|
||||
"max_tokens": 4096,
|
||||
"tools": [
|
||||
{
|
||||
"type": "builtin_function",
|
||||
"function": {
|
||||
"name": "$web_search"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"response": {
|
||||
"id": "chatcmpl-6a6a1fe625464c0001d53f11",
|
||||
"choices": [
|
||||
{
|
||||
"finish_reason": "tool_calls",
|
||||
"index": 0,
|
||||
"logprobs": null,
|
||||
"message": {
|
||||
"content": "",
|
||||
"refusal": null,
|
||||
"role": "assistant",
|
||||
"annotations": null,
|
||||
"audio": null,
|
||||
"function_call": null,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "t-web_search-6a6a1fe62546",
|
||||
"function": {
|
||||
"arguments": "{\"search_result\":{\"search_id\":\"bcfe63c76a6a1fe6680f160001863f8f\"},\"usage\":{\"total_tokens\":3120}}",
|
||||
"name": "$web_search"
|
||||
},
|
||||
"type": "builtin_function"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"created": 1785339878,
|
||||
"model": "kimi-k3",
|
||||
"object": "chat.completion",
|
||||
"service_tier": null,
|
||||
"system_fingerprint": null,
|
||||
"usage": {
|
||||
"completion_tokens": 1,
|
||||
"prompt_tokens": 203,
|
||||
"total_tokens": 204,
|
||||
"completion_tokens_details": null,
|
||||
"prompt_tokens_details": null
|
||||
}
|
||||
},
|
||||
"elapsed_seconds": 3.856849
|
||||
},
|
||||
{
|
||||
"request": {
|
||||
"model": "kimi-k3",
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "你是 Kimi,一个智能搜索助手。\n\n请按照以下步骤处理:\n1. 分析用户问题,识别关键信息需求\n2. 使用 $web_search 工具搜索相关信息\n3. 如果需要更多信息,可以多次调用搜索工具\n4. 综合所有信息,生成准确、全面的答案\n\n注意:\n- 搜索时使用精准的关键词\n- 优先获取最新、最权威的信息\n- 答案要结构清晰,有理有据\n"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "截至 2026 年 7 月,东盟 10 个成员国的首都中,地理距离最近的一对是哪两个?\n请自主完成深度研究:先搜索成员国及官方首都的权威来源;检查还缺哪些坐标证据后,\n至少再执行一次不同的后续搜索来补齐可靠坐标;最后计算全部首都两两之间的大圆距离,\n给出最近一对、距离、计算方法、检索日期,并附可点击的来源链接。不要依赖记忆作答。"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "t-web_search-6a6a1fe62546",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "$web_search",
|
||||
"arguments": "{\"search_result\":{\"search_id\":\"bcfe63c76a6a1fe6680f160001863f8f\"},\"usage\":{\"total_tokens\":3120}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "t-web_search-6a6a1fe62546",
|
||||
"name": "$web_search",
|
||||
"content": "{\"search_result\": {\"search_id\": \"bcfe63c76a6a1fe6680f160001863f8f\"}, \"usage\": {\"total_tokens\": 3120}}"
|
||||
}
|
||||
],
|
||||
"temperature": 1,
|
||||
"max_tokens": 4096,
|
||||
"tools": [
|
||||
{
|
||||
"type": "builtin_function",
|
||||
"function": {
|
||||
"name": "$web_search"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"response": {
|
||||
"id": "chatcmpl-6a6a1fe6f1ac6b519f46c5af",
|
||||
"choices": [
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"index": 0,
|
||||
"logprobs": null,
|
||||
"message": {
|
||||
"content": "",
|
||||
"refusal": null,
|
||||
"role": "assistant",
|
||||
"annotations": null,
|
||||
"audio": null,
|
||||
"function_call": null,
|
||||
"tool_calls": null,
|
||||
"reasoning_content": "The search returned no results. Let me try a different search query."
|
||||
}
|
||||
}
|
||||
],
|
||||
"created": 1785339881,
|
||||
"model": "kimi-k3",
|
||||
"object": "chat.completion",
|
||||
"service_tier": null,
|
||||
"system_fingerprint": null,
|
||||
"usage": {
|
||||
"completion_tokens": 30,
|
||||
"prompt_tokens": 453,
|
||||
"total_tokens": 483,
|
||||
"completion_tokens_details": {
|
||||
"accepted_prediction_tokens": null,
|
||||
"audio_tokens": null,
|
||||
"reasoning_tokens": 15,
|
||||
"rejected_prediction_tokens": null
|
||||
},
|
||||
"prompt_tokens_details": null
|
||||
}
|
||||
},
|
||||
"elapsed_seconds": 4.567783
|
||||
}
|
||||
]
|
||||
},
|
||||
"acceptance": {
|
||||
"checks": {
|
||||
"direct_moonshot_api": true,
|
||||
"exact_model": true,
|
||||
"provider_response_each_turn": false,
|
||||
"hosted_search_tool_declared_each_search_turn": true,
|
||||
"multiple_hosted_searches": false,
|
||||
"reasoning_observed": false,
|
||||
"final_answer_observed": false,
|
||||
"source_links_in_answer": false,
|
||||
"distance_result_in_answer": false
|
||||
},
|
||||
"passed": false,
|
||||
"search_ids": [
|
||||
"bcfe63c76a6a1fe6680f160001863f8f"
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 656,
|
||||
"completion_tokens": 31,
|
||||
"total_tokens": 687,
|
||||
"cached_prompt_tokens": 0,
|
||||
"reasoning_tokens": 15
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
9f9108f22d4afd1cd8dd83cecb4259a1cf0468430b8eb2d04a2a829642140f4f evidence.json
|
||||
@@ -0,0 +1,782 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"experiment_id": "1-2",
|
||||
"evidence_mode": "real_api",
|
||||
"created_at": "2026-07-29T15:57:35.564984+00:00",
|
||||
"canonical_source": "book/chapter1.md#实验-1-2-kimi-k3-原生-agent-能力",
|
||||
"credential_source_env": "MOONSHOT_API_KEY",
|
||||
"credential_value_recorded": false,
|
||||
"host": {
|
||||
"platform": "macOS-26.3-arm64-arm-64bit",
|
||||
"python": "3.11.4 (main, Jul 5 2023, 08:40:20) [Clang 14.0.6 ]",
|
||||
"machine": "arm64"
|
||||
},
|
||||
"repository": {
|
||||
"commit": "4a7f37cf278bd15948c409f14533017c4c7fbc29",
|
||||
"branch": "main",
|
||||
"worktree_dirty": true
|
||||
},
|
||||
"transient_failed_attempts": [
|
||||
{
|
||||
"attempt": 1,
|
||||
"answer": "搜索结果未完整返回,我重新执行搜索。",
|
||||
"validation": {
|
||||
"checks": {
|
||||
"direct_moonshot_api": true,
|
||||
"exact_model": true,
|
||||
"provider_response_each_turn": false,
|
||||
"hosted_search_tool_declared_each_search_turn": true,
|
||||
"multiple_hosted_searches": false,
|
||||
"reasoning_observed": true,
|
||||
"final_answer_observed": true,
|
||||
"source_links_in_answer": false,
|
||||
"distance_result_in_answer": false
|
||||
},
|
||||
"passed": false,
|
||||
"search_ids": [
|
||||
"3b15ca0f6a6a22a74f50a500018810f9"
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 660,
|
||||
"completion_tokens": 99,
|
||||
"total_tokens": 759,
|
||||
"cached_prompt_tokens": 256,
|
||||
"reasoning_tokens": 73
|
||||
}
|
||||
},
|
||||
"api_turns": [
|
||||
{
|
||||
"request": {
|
||||
"model": "kimi-k3",
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "你是 Kimi,一个智能搜索助手。\n\n请按照以下步骤处理:\n1. 分析用户问题,识别关键信息需求\n2. 使用 $web_search 工具搜索相关信息\n3. 如果需要更多信息,可以多次调用搜索工具\n4. 综合所有信息,生成准确、全面的答案\n\n注意:\n- 搜索时使用精准的关键词\n- 优先获取最新、最权威的信息\n- 答案要结构清晰,有理有据\n"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "截至 2026 年 7 月,东盟 10 个成员国的首都中,地理距离最近的一对是哪两个?\n请自主完成深度研究:先搜索成员国及官方首都的权威来源;检查还缺哪些坐标证据后,\n至少再执行一次不同的后续搜索来补齐可靠坐标;最后计算全部首都两两之间的大圆距离,\n给出最近一对、距离、计算方法、检索日期,并附可点击的来源链接。不要依赖记忆作答。"
|
||||
}
|
||||
],
|
||||
"temperature": 1,
|
||||
"max_tokens": 8192,
|
||||
"tools": [
|
||||
{
|
||||
"type": "builtin_function",
|
||||
"function": {
|
||||
"name": "$web_search"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"response": {
|
||||
"id": "chatcmpl-6a6a22a7c48fa700012ef965",
|
||||
"choices": [
|
||||
{
|
||||
"finish_reason": "tool_calls",
|
||||
"index": 0,
|
||||
"logprobs": null,
|
||||
"message": {
|
||||
"content": "",
|
||||
"refusal": null,
|
||||
"role": "assistant",
|
||||
"annotations": null,
|
||||
"audio": null,
|
||||
"function_call": null,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "t-web_search-6a6a22a7c48f",
|
||||
"function": {
|
||||
"arguments": "{\"search_result\":{\"search_id\":\"3b15ca0f6a6a22a74f50a500018810f9\"},\"usage\":{\"total_tokens\":4266}}",
|
||||
"name": "$web_search"
|
||||
},
|
||||
"type": "builtin_function"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"created": 1785340583,
|
||||
"model": "kimi-k3",
|
||||
"object": "chat.completion",
|
||||
"service_tier": null,
|
||||
"system_fingerprint": null,
|
||||
"usage": {
|
||||
"completion_tokens": 1,
|
||||
"prompt_tokens": 203,
|
||||
"total_tokens": 204,
|
||||
"completion_tokens_details": null,
|
||||
"prompt_tokens_details": null
|
||||
}
|
||||
},
|
||||
"elapsed_seconds": 6.277444
|
||||
},
|
||||
{
|
||||
"request": {
|
||||
"model": "kimi-k3",
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "你是 Kimi,一个智能搜索助手。\n\n请按照以下步骤处理:\n1. 分析用户问题,识别关键信息需求\n2. 使用 $web_search 工具搜索相关信息\n3. 如果需要更多信息,可以多次调用搜索工具\n4. 综合所有信息,生成准确、全面的答案\n\n注意:\n- 搜索时使用精准的关键词\n- 优先获取最新、最权威的信息\n- 答案要结构清晰,有理有据\n"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "截至 2026 年 7 月,东盟 10 个成员国的首都中,地理距离最近的一对是哪两个?\n请自主完成深度研究:先搜索成员国及官方首都的权威来源;检查还缺哪些坐标证据后,\n至少再执行一次不同的后续搜索来补齐可靠坐标;最后计算全部首都两两之间的大圆距离,\n给出最近一对、距离、计算方法、检索日期,并附可点击的来源链接。不要依赖记忆作答。"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "t-web_search-6a6a22a7c48f",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "$web_search",
|
||||
"arguments": "{\"search_result\":{\"search_id\":\"3b15ca0f6a6a22a74f50a500018810f9\"},\"usage\":{\"total_tokens\":4266}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "t-web_search-6a6a22a7c48f",
|
||||
"name": "$web_search",
|
||||
"content": "{\"search_result\": {\"search_id\": \"3b15ca0f6a6a22a74f50a500018810f9\"}, \"usage\": {\"total_tokens\": 4266}}"
|
||||
}
|
||||
],
|
||||
"temperature": 1,
|
||||
"max_tokens": 8192,
|
||||
"tools": [
|
||||
{
|
||||
"type": "builtin_function",
|
||||
"function": {
|
||||
"name": "$web_search"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"response": {
|
||||
"id": "chatcmpl-6a6a22a88dc9461ab3e2461e",
|
||||
"choices": [
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"index": 0,
|
||||
"logprobs": null,
|
||||
"message": {
|
||||
"content": "搜索结果未完整返回,我重新执行搜索。",
|
||||
"refusal": null,
|
||||
"role": "assistant",
|
||||
"annotations": null,
|
||||
"audio": null,
|
||||
"function_call": null,
|
||||
"tool_calls": null,
|
||||
"reasoning_content": "The search result didn't include actual content in what I received. Let me look — the tool response only shows search_id and usage, no results content. It seems the results may not have been returned properly, or I need to check. Let me try searching again.\n\nActually, the response format seems truncated. Let me try another search to get the actual content."
|
||||
}
|
||||
}
|
||||
],
|
||||
"created": 1785340586,
|
||||
"model": "kimi-k3",
|
||||
"object": "chat.completion",
|
||||
"service_tier": null,
|
||||
"system_fingerprint": null,
|
||||
"usage": {
|
||||
"completion_tokens": 98,
|
||||
"prompt_tokens": 457,
|
||||
"total_tokens": 555,
|
||||
"completion_tokens_details": {
|
||||
"accepted_prediction_tokens": null,
|
||||
"audio_tokens": null,
|
||||
"reasoning_tokens": 73,
|
||||
"rejected_prediction_tokens": null
|
||||
},
|
||||
"prompt_tokens_details": {
|
||||
"audio_tokens": null,
|
||||
"cached_tokens": 256
|
||||
},
|
||||
"cached_tokens": 256
|
||||
}
|
||||
},
|
||||
"elapsed_seconds": 11.16168
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"attempt": 2,
|
||||
"answer": "抱歉,我无法获取足够的信息来回答您的问题。",
|
||||
"validation": {
|
||||
"checks": {
|
||||
"direct_moonshot_api": true,
|
||||
"exact_model": true,
|
||||
"provider_response_each_turn": false,
|
||||
"hosted_search_tool_declared_each_search_turn": true,
|
||||
"multiple_hosted_searches": false,
|
||||
"reasoning_observed": true,
|
||||
"final_answer_observed": false,
|
||||
"source_links_in_answer": false,
|
||||
"distance_result_in_answer": false
|
||||
},
|
||||
"passed": false,
|
||||
"search_ids": [
|
||||
"df239d206a6a22b64f50a50001881119"
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 652,
|
||||
"completion_tokens": 93,
|
||||
"total_tokens": 745,
|
||||
"cached_prompt_tokens": 256,
|
||||
"reasoning_tokens": 77
|
||||
}
|
||||
},
|
||||
"api_turns": [
|
||||
{
|
||||
"request": {
|
||||
"model": "kimi-k3",
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "你是 Kimi,一个智能搜索助手。\n\n请按照以下步骤处理:\n1. 分析用户问题,识别关键信息需求\n2. 使用 $web_search 工具搜索相关信息\n3. 如果需要更多信息,可以多次调用搜索工具\n4. 综合所有信息,生成准确、全面的答案\n\n注意:\n- 搜索时使用精准的关键词\n- 优先获取最新、最权威的信息\n- 答案要结构清晰,有理有据\n"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "截至 2026 年 7 月,东盟 10 个成员国的首都中,地理距离最近的一对是哪两个?\n请自主完成深度研究:先搜索成员国及官方首都的权威来源;检查还缺哪些坐标证据后,\n至少再执行一次不同的后续搜索来补齐可靠坐标;最后计算全部首都两两之间的大圆距离,\n给出最近一对、距离、计算方法、检索日期,并附可点击的来源链接。不要依赖记忆作答。"
|
||||
}
|
||||
],
|
||||
"temperature": 1,
|
||||
"max_tokens": 8192,
|
||||
"tools": [
|
||||
{
|
||||
"type": "builtin_function",
|
||||
"function": {
|
||||
"name": "$web_search"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"response": {
|
||||
"id": "chatcmpl-6a6a22b67dcaa60001ac54e8",
|
||||
"choices": [
|
||||
{
|
||||
"finish_reason": "tool_calls",
|
||||
"index": 0,
|
||||
"logprobs": null,
|
||||
"message": {
|
||||
"content": "",
|
||||
"refusal": null,
|
||||
"role": "assistant",
|
||||
"annotations": null,
|
||||
"audio": null,
|
||||
"function_call": null,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "t-web_search-6a6a22b67dca",
|
||||
"function": {
|
||||
"arguments": "{\"search_result\":{\"search_id\":\"df239d206a6a22b64f50a50001881119\"},\"usage\":{\"total_tokens\":5372}}",
|
||||
"name": "$web_search"
|
||||
},
|
||||
"type": "builtin_function"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"created": 1785340598,
|
||||
"model": "kimi-k3",
|
||||
"object": "chat.completion",
|
||||
"service_tier": null,
|
||||
"system_fingerprint": null,
|
||||
"usage": {
|
||||
"completion_tokens": 1,
|
||||
"prompt_tokens": 203,
|
||||
"total_tokens": 204,
|
||||
"completion_tokens_details": null,
|
||||
"prompt_tokens_details": null
|
||||
}
|
||||
},
|
||||
"elapsed_seconds": 1.740362
|
||||
},
|
||||
{
|
||||
"request": {
|
||||
"model": "kimi-k3",
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "你是 Kimi,一个智能搜索助手。\n\n请按照以下步骤处理:\n1. 分析用户问题,识别关键信息需求\n2. 使用 $web_search 工具搜索相关信息\n3. 如果需要更多信息,可以多次调用搜索工具\n4. 综合所有信息,生成准确、全面的答案\n\n注意:\n- 搜索时使用精准的关键词\n- 优先获取最新、最权威的信息\n- 答案要结构清晰,有理有据\n"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "截至 2026 年 7 月,东盟 10 个成员国的首都中,地理距离最近的一对是哪两个?\n请自主完成深度研究:先搜索成员国及官方首都的权威来源;检查还缺哪些坐标证据后,\n至少再执行一次不同的后续搜索来补齐可靠坐标;最后计算全部首都两两之间的大圆距离,\n给出最近一对、距离、计算方法、检索日期,并附可点击的来源链接。不要依赖记忆作答。"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "t-web_search-6a6a22b67dca",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "$web_search",
|
||||
"arguments": "{\"search_result\":{\"search_id\":\"df239d206a6a22b64f50a50001881119\"},\"usage\":{\"total_tokens\":5372}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "t-web_search-6a6a22b67dca",
|
||||
"name": "$web_search",
|
||||
"content": "{\"search_result\": {\"search_id\": \"df239d206a6a22b64f50a50001881119\"}, \"usage\": {\"total_tokens\": 5372}}"
|
||||
}
|
||||
],
|
||||
"temperature": 1,
|
||||
"max_tokens": 8192,
|
||||
"tools": [
|
||||
{
|
||||
"type": "builtin_function",
|
||||
"function": {
|
||||
"name": "$web_search"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"response": {
|
||||
"id": "chatcmpl-6a6a22b7a5dea32f85466e4b",
|
||||
"choices": [
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"index": 0,
|
||||
"logprobs": null,
|
||||
"message": {
|
||||
"content": "",
|
||||
"refusal": null,
|
||||
"role": "assistant",
|
||||
"annotations": null,
|
||||
"audio": null,
|
||||
"function_call": null,
|
||||
"tool_calls": null,
|
||||
"reasoning_content": "The web_search result only returned a search_id without actual content. It seems the search results are not included in the response. Let me try again with a different query to get actual results.\n\nThe result format shows only search_id, no snippets or content. Maybe I need to call it again or the results are implicit. Let me try another search to see if I get content."
|
||||
}
|
||||
}
|
||||
],
|
||||
"created": 1785340601,
|
||||
"model": "kimi-k3",
|
||||
"object": "chat.completion",
|
||||
"service_tier": null,
|
||||
"system_fingerprint": null,
|
||||
"usage": {
|
||||
"completion_tokens": 92,
|
||||
"prompt_tokens": 449,
|
||||
"total_tokens": 541,
|
||||
"completion_tokens_details": {
|
||||
"accepted_prediction_tokens": null,
|
||||
"audio_tokens": null,
|
||||
"reasoning_tokens": 77,
|
||||
"rejected_prediction_tokens": null
|
||||
},
|
||||
"prompt_tokens_details": {
|
||||
"audio_tokens": null,
|
||||
"cached_tokens": 256
|
||||
},
|
||||
"cached_tokens": 256
|
||||
}
|
||||
},
|
||||
"elapsed_seconds": 6.951842
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"attempt": 3,
|
||||
"answer": "抱歉,我无法获取足够的信息来回答您的问题。",
|
||||
"validation": {
|
||||
"checks": {
|
||||
"direct_moonshot_api": true,
|
||||
"exact_model": true,
|
||||
"provider_response_each_turn": false,
|
||||
"hosted_search_tool_declared_each_search_turn": true,
|
||||
"multiple_hosted_searches": false,
|
||||
"reasoning_observed": true,
|
||||
"final_answer_observed": false,
|
||||
"source_links_in_answer": false,
|
||||
"distance_result_in_answer": false
|
||||
},
|
||||
"passed": false,
|
||||
"search_ids": [
|
||||
"7cf26c246a6a22c34f50a50001881141"
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 654,
|
||||
"completion_tokens": 54,
|
||||
"total_tokens": 708,
|
||||
"cached_prompt_tokens": 256,
|
||||
"reasoning_tokens": 38
|
||||
}
|
||||
},
|
||||
"api_turns": [
|
||||
{
|
||||
"request": {
|
||||
"model": "kimi-k3",
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "你是 Kimi,一个智能搜索助手。\n\n请按照以下步骤处理:\n1. 分析用户问题,识别关键信息需求\n2. 使用 $web_search 工具搜索相关信息\n3. 如果需要更多信息,可以多次调用搜索工具\n4. 综合所有信息,生成准确、全面的答案\n\n注意:\n- 搜索时使用精准的关键词\n- 优先获取最新、最权威的信息\n- 答案要结构清晰,有理有据\n"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "截至 2026 年 7 月,东盟 10 个成员国的首都中,地理距离最近的一对是哪两个?\n请自主完成深度研究:先搜索成员国及官方首都的权威来源;检查还缺哪些坐标证据后,\n至少再执行一次不同的后续搜索来补齐可靠坐标;最后计算全部首都两两之间的大圆距离,\n给出最近一对、距离、计算方法、检索日期,并附可点击的来源链接。不要依赖记忆作答。"
|
||||
}
|
||||
],
|
||||
"temperature": 1,
|
||||
"max_tokens": 8192,
|
||||
"tools": [
|
||||
{
|
||||
"type": "builtin_function",
|
||||
"function": {
|
||||
"name": "$web_search"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"response": {
|
||||
"id": "chatcmpl-6a6a22c38fcd8100016b9ae6",
|
||||
"choices": [
|
||||
{
|
||||
"finish_reason": "tool_calls",
|
||||
"index": 0,
|
||||
"logprobs": null,
|
||||
"message": {
|
||||
"content": "",
|
||||
"refusal": null,
|
||||
"role": "assistant",
|
||||
"annotations": null,
|
||||
"audio": null,
|
||||
"function_call": null,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "t-web_search-6a6a22c38fcd",
|
||||
"function": {
|
||||
"arguments": "{\"search_result\":{\"search_id\":\"7cf26c246a6a22c34f50a50001881141\"},\"usage\":{\"total_tokens\":3583}}",
|
||||
"name": "$web_search"
|
||||
},
|
||||
"type": "builtin_function"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"created": 1785340611,
|
||||
"model": "kimi-k3",
|
||||
"object": "chat.completion",
|
||||
"service_tier": null,
|
||||
"system_fingerprint": null,
|
||||
"usage": {
|
||||
"completion_tokens": 1,
|
||||
"prompt_tokens": 203,
|
||||
"total_tokens": 204,
|
||||
"completion_tokens_details": null,
|
||||
"prompt_tokens_details": null
|
||||
}
|
||||
},
|
||||
"elapsed_seconds": 1.362886
|
||||
},
|
||||
{
|
||||
"request": {
|
||||
"model": "kimi-k3",
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "你是 Kimi,一个智能搜索助手。\n\n请按照以下步骤处理:\n1. 分析用户问题,识别关键信息需求\n2. 使用 $web_search 工具搜索相关信息\n3. 如果需要更多信息,可以多次调用搜索工具\n4. 综合所有信息,生成准确、全面的答案\n\n注意:\n- 搜索时使用精准的关键词\n- 优先获取最新、最权威的信息\n- 答案要结构清晰,有理有据\n"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "截至 2026 年 7 月,东盟 10 个成员国的首都中,地理距离最近的一对是哪两个?\n请自主完成深度研究:先搜索成员国及官方首都的权威来源;检查还缺哪些坐标证据后,\n至少再执行一次不同的后续搜索来补齐可靠坐标;最后计算全部首都两两之间的大圆距离,\n给出最近一对、距离、计算方法、检索日期,并附可点击的来源链接。不要依赖记忆作答。"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "t-web_search-6a6a22c38fcd",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "$web_search",
|
||||
"arguments": "{\"search_result\":{\"search_id\":\"7cf26c246a6a22c34f50a50001881141\"},\"usage\":{\"total_tokens\":3583}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "t-web_search-6a6a22c38fcd",
|
||||
"name": "$web_search",
|
||||
"content": "{\"search_result\": {\"search_id\": \"7cf26c246a6a22c34f50a50001881141\"}, \"usage\": {\"total_tokens\": 3583}}"
|
||||
}
|
||||
],
|
||||
"temperature": 1,
|
||||
"max_tokens": 8192,
|
||||
"tools": [
|
||||
{
|
||||
"type": "builtin_function",
|
||||
"function": {
|
||||
"name": "$web_search"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"response": {
|
||||
"id": "chatcmpl-6a6a22e2adf8be004bbd9338",
|
||||
"choices": [
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"index": 0,
|
||||
"logprobs": null,
|
||||
"message": {
|
||||
"content": "",
|
||||
"refusal": null,
|
||||
"role": "assistant",
|
||||
"annotations": null,
|
||||
"audio": null,
|
||||
"function_call": null,
|
||||
"tool_calls": null,
|
||||
"reasoning_content": "Search returned nothing visible. Need call again with query. The user asked deep research with multiple searches and links. Need gather authoritative sources. Search web for ASEAN member states capitals official."
|
||||
}
|
||||
}
|
||||
],
|
||||
"created": 1785340653,
|
||||
"model": "kimi-k3",
|
||||
"object": "chat.completion",
|
||||
"service_tier": null,
|
||||
"system_fingerprint": null,
|
||||
"usage": {
|
||||
"completion_tokens": 53,
|
||||
"prompt_tokens": 451,
|
||||
"total_tokens": 504,
|
||||
"completion_tokens_details": {
|
||||
"accepted_prediction_tokens": null,
|
||||
"audio_tokens": null,
|
||||
"reasoning_tokens": 38,
|
||||
"rejected_prediction_tokens": null
|
||||
},
|
||||
"prompt_tokens_details": {
|
||||
"audio_tokens": null,
|
||||
"cached_tokens": 256
|
||||
},
|
||||
"cached_tokens": 256
|
||||
}
|
||||
},
|
||||
"elapsed_seconds": 44.713356
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"run": {
|
||||
"provider": "moonshot",
|
||||
"model": "kimi-k3",
|
||||
"base_url": "https://api.moonshot.cn/v1",
|
||||
"question": "截至 2026 年 7 月,东盟 10 个成员国的首都中,地理距离最近的一对是哪两个?\n请自主完成深度研究:先搜索成员国及官方首都的权威来源;检查还缺哪些坐标证据后,\n至少再执行一次不同的后续搜索来补齐可靠坐标;最后计算全部首都两两之间的大圆距离,\n给出最近一对、距离、计算方法、检索日期,并附可点击的来源链接。不要依赖记忆作答。",
|
||||
"answer": "抱歉,我无法获取足够的信息来回答您的问题。",
|
||||
"trace": [
|
||||
{
|
||||
"iteration": 1,
|
||||
"type": "action",
|
||||
"tool": "$web_search",
|
||||
"args": {
|
||||
"search_result": {
|
||||
"search_id": "7cf26c246a6a22c34f50a50001881141"
|
||||
},
|
||||
"usage": {
|
||||
"total_tokens": 3583
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"iteration": 1,
|
||||
"type": "observation",
|
||||
"tool": "$web_search",
|
||||
"content": "{\"search_result\": {\"search_id\": \"7cf26c246a6a22c34f50a50001881141\"}, \"usage\": {\"total_tokens\": 3583}}"
|
||||
},
|
||||
{
|
||||
"iteration": 2,
|
||||
"type": "thought",
|
||||
"content": "Search returned nothing visible. Need call again with query. The user asked deep research with multiple searches and links. Need gather authoritative sources. Search web for ASEAN member states capitals official."
|
||||
}
|
||||
],
|
||||
"api_turns": [
|
||||
{
|
||||
"request": {
|
||||
"model": "kimi-k3",
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "你是 Kimi,一个智能搜索助手。\n\n请按照以下步骤处理:\n1. 分析用户问题,识别关键信息需求\n2. 使用 $web_search 工具搜索相关信息\n3. 如果需要更多信息,可以多次调用搜索工具\n4. 综合所有信息,生成准确、全面的答案\n\n注意:\n- 搜索时使用精准的关键词\n- 优先获取最新、最权威的信息\n- 答案要结构清晰,有理有据\n"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "截至 2026 年 7 月,东盟 10 个成员国的首都中,地理距离最近的一对是哪两个?\n请自主完成深度研究:先搜索成员国及官方首都的权威来源;检查还缺哪些坐标证据后,\n至少再执行一次不同的后续搜索来补齐可靠坐标;最后计算全部首都两两之间的大圆距离,\n给出最近一对、距离、计算方法、检索日期,并附可点击的来源链接。不要依赖记忆作答。"
|
||||
}
|
||||
],
|
||||
"temperature": 1,
|
||||
"max_tokens": 8192,
|
||||
"tools": [
|
||||
{
|
||||
"type": "builtin_function",
|
||||
"function": {
|
||||
"name": "$web_search"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"response": {
|
||||
"id": "chatcmpl-6a6a22c38fcd8100016b9ae6",
|
||||
"choices": [
|
||||
{
|
||||
"finish_reason": "tool_calls",
|
||||
"index": 0,
|
||||
"logprobs": null,
|
||||
"message": {
|
||||
"content": "",
|
||||
"refusal": null,
|
||||
"role": "assistant",
|
||||
"annotations": null,
|
||||
"audio": null,
|
||||
"function_call": null,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "t-web_search-6a6a22c38fcd",
|
||||
"function": {
|
||||
"arguments": "{\"search_result\":{\"search_id\":\"7cf26c246a6a22c34f50a50001881141\"},\"usage\":{\"total_tokens\":3583}}",
|
||||
"name": "$web_search"
|
||||
},
|
||||
"type": "builtin_function"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"created": 1785340611,
|
||||
"model": "kimi-k3",
|
||||
"object": "chat.completion",
|
||||
"service_tier": null,
|
||||
"system_fingerprint": null,
|
||||
"usage": {
|
||||
"completion_tokens": 1,
|
||||
"prompt_tokens": 203,
|
||||
"total_tokens": 204,
|
||||
"completion_tokens_details": null,
|
||||
"prompt_tokens_details": null
|
||||
}
|
||||
},
|
||||
"elapsed_seconds": 1.362886
|
||||
},
|
||||
{
|
||||
"request": {
|
||||
"model": "kimi-k3",
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": "你是 Kimi,一个智能搜索助手。\n\n请按照以下步骤处理:\n1. 分析用户问题,识别关键信息需求\n2. 使用 $web_search 工具搜索相关信息\n3. 如果需要更多信息,可以多次调用搜索工具\n4. 综合所有信息,生成准确、全面的答案\n\n注意:\n- 搜索时使用精准的关键词\n- 优先获取最新、最权威的信息\n- 答案要结构清晰,有理有据\n"
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "截至 2026 年 7 月,东盟 10 个成员国的首都中,地理距离最近的一对是哪两个?\n请自主完成深度研究:先搜索成员国及官方首都的权威来源;检查还缺哪些坐标证据后,\n至少再执行一次不同的后续搜索来补齐可靠坐标;最后计算全部首都两两之间的大圆距离,\n给出最近一对、距离、计算方法、检索日期,并附可点击的来源链接。不要依赖记忆作答。"
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "t-web_search-6a6a22c38fcd",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "$web_search",
|
||||
"arguments": "{\"search_result\":{\"search_id\":\"7cf26c246a6a22c34f50a50001881141\"},\"usage\":{\"total_tokens\":3583}}"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "tool",
|
||||
"tool_call_id": "t-web_search-6a6a22c38fcd",
|
||||
"name": "$web_search",
|
||||
"content": "{\"search_result\": {\"search_id\": \"7cf26c246a6a22c34f50a50001881141\"}, \"usage\": {\"total_tokens\": 3583}}"
|
||||
}
|
||||
],
|
||||
"temperature": 1,
|
||||
"max_tokens": 8192,
|
||||
"tools": [
|
||||
{
|
||||
"type": "builtin_function",
|
||||
"function": {
|
||||
"name": "$web_search"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"response": {
|
||||
"id": "chatcmpl-6a6a22e2adf8be004bbd9338",
|
||||
"choices": [
|
||||
{
|
||||
"finish_reason": "stop",
|
||||
"index": 0,
|
||||
"logprobs": null,
|
||||
"message": {
|
||||
"content": "",
|
||||
"refusal": null,
|
||||
"role": "assistant",
|
||||
"annotations": null,
|
||||
"audio": null,
|
||||
"function_call": null,
|
||||
"tool_calls": null,
|
||||
"reasoning_content": "Search returned nothing visible. Need call again with query. The user asked deep research with multiple searches and links. Need gather authoritative sources. Search web for ASEAN member states capitals official."
|
||||
}
|
||||
}
|
||||
],
|
||||
"created": 1785340653,
|
||||
"model": "kimi-k3",
|
||||
"object": "chat.completion",
|
||||
"service_tier": null,
|
||||
"system_fingerprint": null,
|
||||
"usage": {
|
||||
"completion_tokens": 53,
|
||||
"prompt_tokens": 451,
|
||||
"total_tokens": 504,
|
||||
"completion_tokens_details": {
|
||||
"accepted_prediction_tokens": null,
|
||||
"audio_tokens": null,
|
||||
"reasoning_tokens": 38,
|
||||
"rejected_prediction_tokens": null
|
||||
},
|
||||
"prompt_tokens_details": {
|
||||
"audio_tokens": null,
|
||||
"cached_tokens": 256
|
||||
},
|
||||
"cached_tokens": 256
|
||||
}
|
||||
},
|
||||
"elapsed_seconds": 44.713356
|
||||
}
|
||||
]
|
||||
},
|
||||
"acceptance": {
|
||||
"checks": {
|
||||
"direct_moonshot_api": true,
|
||||
"exact_model": true,
|
||||
"provider_response_each_turn": false,
|
||||
"hosted_search_tool_declared_each_search_turn": true,
|
||||
"multiple_hosted_searches": false,
|
||||
"reasoning_observed": true,
|
||||
"final_answer_observed": false,
|
||||
"source_links_in_answer": false,
|
||||
"distance_result_in_answer": false
|
||||
},
|
||||
"passed": false,
|
||||
"search_ids": [
|
||||
"7cf26c246a6a22c34f50a50001881141"
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 654,
|
||||
"completion_tokens": 54,
|
||||
"total_tokens": 708,
|
||||
"cached_prompt_tokens": 256,
|
||||
"reasoning_tokens": 38
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
58bd28a19bc6e5548d93bec3b27b75bc49fd398cf5935fb8390d720e49e3aebf evidence.json
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
3e15f85e5710d58ea10ed5ca63b80c7d415eaeedcd1f86ea9e1ca44da9dd48e6 evidence.json
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
64a93f573bb8fe7ab3ca63672e935816ff148c553bddf01f0149b4f6a64221e6 evidence.json
|
||||
Reference in New Issue
Block a user