1. OpenHands 在 Docker 里为什么总说英文
OpenHands 是一个能在容器里自主读写代码、跑命令、开浏览器的 AI 开发代理,很多人用 Docker 把它跑起来之后,第一反应是:我明明用中文提问,它却经常用英文回我,甚至同一轮对话里中英混杂。这个问题在 Docker 部署场景下尤其明显,因为容器内的默认提示词模板、环境变量、以及你挂载的配置文件三者会互相覆盖,谁生效取决于启动顺序。
我实测下来,输出语言不稳定通常来自三个层面:第一层是容器镜像里内置的user_prompt.j2模板,它默认是英文指令;第二层是环境变量,比如LLM_*系列参数决定了模型走哪个通道、用什么默认行为;第三层是你在 Web 界面里输入的提示词,它只在单轮对话里起作用,不会持久化到系统提示。很多人只改了界面提示词,重启容器后又变回英文,就是因为模板层没动。
这篇内容适合两类人:一类是刚用 Docker 跑起 OpenHands、想让对话稳定输出中文的开发者;另一类是同时用好几个 AI 编码工具、Key 和 API 地址散落在各处、想统一接入通道的人。下面我会从环境变量、配置文件、提示词模板三个层面给出可复制的配置,并用一次真实的中文任务对话验证输出是否稳定,最后说明怎么用 TaoToken 把模型通道收敛到一处,避免每换一个工具就重新配一遍 Key。
2. 前置准备:TaoToken 统一模型通道
在改 OpenHands 的语言配置之前,先把模型接入通道理顺。OpenHands 支持自定义 LLM 的 base_url 和 api_key,如果你同时还在用其他编码工具,每个工具各配一套 Key,时间一长自己都记不清哪个 Key 对应哪个服务。TaoToken 的作用就是提供一个统一的 API 入口,OpenHands、其他编码工具、脚本调用都走同一个地址和同一把 Key,配置集中在一处,排查问题也简单。
你需要先拿到两样东西:一个 API Key,以及确认要用的模型名。API Key 在控制台里创建,地址是 https://taotoken.net/api-keys ,创建后复制保存,后面写进环境变量。API 的基础地址是 https://taotoken.net/api ,注意这个地址不带任何查询参数,直接作为 base_url 使用。模型名按你实际要用的填,比如常见的对话模型或编码模型,具体可用列表在文档里能查到,接入文档在 https://taotoken.net/doc 。
这里有个容易踩的坑:OpenHands 的 LLM 配置里,base_url 和 api_key 是分开传的,base_url 要写到/api这一层,不要自己再拼/v1之类的路径,否则请求会 404。我试过在 base_url 后面多加一段,结果容器日志里一直报连接失败,排查了半天才发现是路径拼错了。统一走 TaoToken 之后,你只需要记住一个地址和一把 Key,换工具时改的只是工具侧的配置,通道本身不动。
3. 可复制的 Docker 启动参数与 config.toml 骨架
先说提示词模板这一层,这是让 OpenHands 稳定输出中文最直接的手段。在本地创建一个user_prompt.j2文件,内容就一行中文指令:
echo "Always respond in 中文" > ./user_prompt.j2然后启动容器时把这个文件挂载到镜像内的模板路径/app/openhands/agenthub/codeact_agent/prompts/user_prompt.j2。这样容器每次启动都会用你的模板覆盖内置英文模板,系统提示层就固定成中文了。
完整的 Docker 启动命令如下,我把关键参数都标出来:
docker run -it --rm --pull=always \ -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:0.20-nikolaik \ -e LOG_ALL_EVENTS=true \ -e LLM_BASE_URL="https://taotoken.net/api" \ -e LLM_API_KEY="你的TaoToken Key" \ -e LLM_MODEL="你的模型名" \ -e WORKSPACE_MOUNT_PATH="/home/你的用户名/你的工作目录" \ -v "/home/你的用户名/你的工作目录":/opt/workspace_base \ -v /var/run/docker.sock:/var/run/docker.sock \ -v ./user_prompt.j2:/app/openhands/agenthub/codeact_agent/prompts/user_prompt.j2 \ -p 3000:3000 \ --add-host host.docker.internal:host-gateway \ --name openhands-app \ docker.all-hands.dev/all-hands-ai/openhands:0.20几个参数需要单独说明。--rm表示退出容器后不保留容器本身,但要注意,这跟对话内容是否保存是两回事:OpenHands 在对话里写的代码,退出对话后不会自动留在工作目录之外,所以写完代码要立刻下载到本地或推到代码仓库,别指望容器帮你留着。WORKSPACE_MOUNT_PATH和对应的-v挂载是把你本地目录映射进容器,方便它读写你的项目文件,如果你只是测试、不需要工作目录,可以把这两行连同-e WORKSPACE_MOUNT_PATH一起删掉。
关于SANDBOX_USER_ID=$(id -u)这一行,官方文档里有,但我实测时删掉了。原因是它用当前用户的权限标识(比如 1000)去运行 OpenHands 服务,结果容器内/.openhands-state/.jwt_secret因为权限不足写不进去,服务直接起不来,报错就是PermissionError: [Errno 13] Permission denied: '/.openhands-state/.jwt_secret'。删掉这行后服务能正常启动,代价是容器可能以 root 身份读写你的工作目录,导致本地文件权限变化,类 Unix 系统下重新chmod一下就行。
如果你更习惯用配置文件而不是环境变量,可以在工作目录下放一个config.toml,骨架如下:
[core] workspace_base = "/opt/workspace_base" [llm] model = "你的模型名" base_url = "https://taotoken.net/api" api_key = "你的TaoToken Key"环境变量和 config.toml 同时存在时,环境变量优先级更高,所以建议二选一,别两边都写,否则改了一处没生效会让人很困惑。
4. 验证请求:一次中文任务对话
配置改完,启动容器,浏览器打开http://localhost:3000,进入 OpenHands 的 Web 界面。验证方法很简单:新建一个对话,用中文提一个需要它动手的任务,比如「在当前工作目录创建一个 hello.py,打印一句中文问候,然后运行它」。
观察三个点。第一,它的回复语言是不是中文,包括思考过程、工具调用说明、最终总结。第二,它执行命令时的注释和输出说明是不是中文。第三,连续追问两三轮,看语言会不会漂回英文。我实测下来,挂载了user_prompt.j2之后,整个对话过程基本稳定在中文,包括它调用终端、读写文件时的说明文字。
如果你在界面里看到它偶尔还是蹦英文单词,先别急着改配置,检查一下是不是模型本身对中文指令的遵循度问题。有些模型对系统提示的服从性弱,这时候可以在对话开头再补一句「请始终用中文回复」,作为单轮强化。但要注意,这种界面里输入的提示词不会持久化,重启容器就没了,真正起长期作用的是模板层。
验证通过后,你可以把这次对话里用到的模型名、base_url 记下来,因为后面如果换工具,通道配置是复用的。TaoToken 的模型对话入口在 https://taotoken.net/models ,想先单独试试模型输出中文的效果,可以在那里直接对话,确认模型侧没问题,再回到 OpenHands 里排查配置。
5. 本篇常见错误排查
报错一:PermissionError: [Errno 13] Permission denied: '/.openhands-state/.jwt_secret'
这是最典型的启动失败。原因就是前面说的SANDBOX_USER_ID=$(id -u)导致服务以非 root 身份运行,但状态目录权限不够。解决办法是删掉这行环境变量,让容器用默认身份启动。删掉后如果本地工作目录出现权限问题,用sudo chown -R $(id -u):$(id -g) 你的工作目录修一下。
报错二:容器起来了,但对话一直转圈或报连接错误
先检查LLM_BASE_URL是不是写成了https://taotoken.net/api,不要多加/v1或其他路径。再检查LLM_API_KEY有没有多余空格或换行。最后确认模型名拼写正确。这三项任意一项错了,都会表现为连接失败或鉴权失败。
报错三:输出还是英文,模板没生效
确认挂载路径写的是/app/openhands/agenthub/codeact_agent/prompts/user_prompt.j2,一个字符都不能差。另外确认本地user_prompt.j2文件确实存在且内容非空。如果用的是相对路径./user_prompt.j2,要确保你执行docker run时所在目录就是文件所在目录。
报错四:退出容器后代码没了
这是--rm加对话不持久化共同造成的。--rm删的是容器,对话内容不保存是 OpenHands 自身行为。养成习惯:写完代码立刻下载到本地或推到代码仓库,别等退出。
报错五:工作目录里的文件读不出来
如果你删了SANDBOX_USER_ID,容器可能以 root 身份写文件,导致本地用户读不了。用chmod或chown把权限改回来即可。这也是删那行参数的副作用,权衡一下:要么接受偶尔改权限,要么保留那行但解决 jwt_secret 的权限问题。
6. 把通道收敛到一处,长期编码更省心
语言配置解决的是输出问题,通道配置解决的是接入问题,两件事最好一起理顺。如果你只是偶尔跑一次 OpenHands,环境变量里写死 Key 就够了;但如果你长期用 AI 做编码、还同时跑其他 Agent 工具,建议把模型通道统一到 TaoToken,所有工具都指向同一个 base_url 和同一把 Key。这样换工具时只改工具侧,通道不动,排查问题时也能快速定位是工具配置错了还是通道本身有问题。
长期编码和 Agent 场景可以看 Coding Plan,地址是 https://taotoken.net/coding-plan ,它更适合需要持续调用、多工具协同的情况。控制台在 https://taotoken.net/console ,API Key 管理在 https://taotoken.net/api-keys ,接入文档在 https://taotoken.net/doc 。如果你用的是 Claude Code 这类工具,对应的接入说明在 https://taotoken.net/claude-code 。
回到 OpenHands 本身,我的经验是:模板层管语言,环境变量管通道,界面提示词只管单轮。三层各司其职,别指望改一处解决所有问题。把user_prompt.j2挂好、把 TaoToken 的 base_url 和 Key 写对,重启容器,用中文提一个动手任务验证一遍,基本就稳了。剩下的就是记得写完代码及时保存,别让--rm和对话不持久化坑了你。