DeepSeek Harness 一键后台启停:关掉终端也能继续跑
DeepSeek Harness 一键启动脚本
最近 DeepSeek Harness(dsh)热度不低,官方给的启动方式很简单:
npx @deepseek-ai/dsh web

命令本身没毛病,但它必须前台运行:终端窗口一关,dsh 就跟着停了。想让 dsh 像服务一样常驻后台,官方没给现成方案,于是就有了下面这套 PowerShell 一键脚本。配好之后,在任意目录敲 dsh start、dsh stop、dsh status 就能管理它,浏览器访问 http://127.0.0.1:3080 直接进 Web UI。
一、解决了什么问题
| 痛点 | 脚本的处理方式 |
|---|---|
| 前台运行,关终端就停 | 用 Start-Process 后台拉起,-WindowStyle Hidden 隐藏窗口,彻底脱离终端 |
| 不知道是否已在运行、PID 是多少 | start / status 自动检测,PID 记录在独立文件,随时可查 |
| 停止时要自己翻进程列表 | stop 优先按记录的 PID 精确停止;记录丢了还能按 3080 端口反查兜底 |
| 命令难记 | 封装成 dsh start / dsh stop / dsh status / dsh 四个短命令 |
二、环境要求
不需要额外装什么,下面三样确认能用就行:
| 工具 | 检查命令 | 说明 |
|---|---|---|
| Node.js | node --version |
必需,dsh 通过 npx 运行 |
| npm | npm --version |
随 Node.js 一起安装 |
| PowerShell | 打开即用 | Windows 自带(脚本针对 Windows PowerShell 5.1 编写) |
注意:不用单独装 pnpm。这套方案走 npx 路线,绕开了源码构建对 pnpm 的依赖。
三、配置步骤
步骤 1:创建辅助目录
在用户主目录下建一个放脚本的目录。别用 ~/.dsh,那个目录 dsh 项目自己要用(存 credentials、profiles 等数据):
New-Item -ItemType Directory -Force "$HOME\.dsh-launcher"
步骤 2:创建主脚本 dsh.ps1
在 C:\Users\<你的用户名>\.dsh-launcher\dsh.ps1 创建脚本文件,完整源码见文末附录 A。
这里有个最大的坑:脚本必须保存为 UTF-8 with BOM 编码,否则 Windows PowerShell 5.1 会把中文按 GBK 误读,报一堆 UnexpectedToken 语法错误。用 VS Code 的话,右下角编码选「Save with Encoding」→「UTF-8 with BOM」即可。
如果文件已经存成了无 BOM 的 UTF-8,可以用下面命令就地转换(路径按需替换):
$c = Get-Content "$HOME\.dsh-launcher\dsh.ps1" -Raw -Encoding UTF8
[System.IO.File]::WriteAllText("$HOME\.dsh-launcher\dsh.ps1", $c, (New-Object System.Text.UTF8Encoding $true))
步骤 3:把 dsh 命令注册进 Profile
打开用户级 PowerShell Profile:
notepad $PROFILE.CurrentUserAllHosts
路径通常是 C:\Users\<你的用户名>\Documents\WindowsPowerShell\profile.ps1,不存在会提示新建。在文件末尾追加下面内容(原有内容比如 conda 初始化块,保留别动):
# dsh launcher - DeepSeek Harness 后台管理
function dsh {
& (Join-Path $HOME '.dsh-launcher\dsh.ps1') @args
}
保存后新开一个 PowerShell 窗口(或者先执行 . $PROFILE),dsh 命令就生效了。
四、使用方法
任意目录打开 PowerShell:
| 命令 | 作用 | 典型输出 |
|---|---|---|
dsh start |
后台启动 dsh(已在运行则提示,不重复启动) | dsh 已启动 (PID 28072),访问 http://127.0.0.1:3080 |
dsh stop |
停止 dsh(优先 PID 记录,否则按端口反查) | 正在停止 dsh (通过PID 记录找到 PID 28072) ... |
dsh status |
查看运行状态 | dsh 正在运行。 PID : 28072 (来自 PID 记录) |
dsh |
显示帮助 | 列出所有用法 |
下面是我本机实际跑一遍的效果(依次执行 start → status → stop):

五、脚本在干什么
脚本在 ~\.dsh-launcher 下维护几个辅助文件:
| 文件 | 作用 |
|---|---|
dsh.ps1 |
主脚本 |
dsh.pid |
记录当前 dsh 进程的 PID(start 写入,stop 删除) |
dsh.log |
标准输出日志 |
dsh.err.log |
标准错误日志 |
三个核心逻辑:
- 怎么判断「是不是 dsh 进程」:
npx @deepseek-ai/dsh web最终是由一个 node.exe 跑的。脚本通过 Win32_Process 拿到进程命令行,同时满足「进程名是 node.exe」+「命令行含 dsh」才认定是 dsh,避免误杀其它 node 程序。 - start 幂等:先查 PID 文件,失效就清理;再按 3080 端口兜底查一次,已经在跑就直接提示;确认没跑才拉起,然后轮询端口(最多 30 秒)拿到真实 PID 写入文件。
- stop 兜底:有有效 PID 记录就按记录杀;没有(比如 dsh 是别人起的、或旧版本脚本起的)就按 3080 端口反查,找到一样能停;都找不到才提示「未运行」。
六、踩过的坑
都是实际配的过程中踩出来又解决的,换电脑重配时注意:
- 编码必须 UTF-8 with BOM:最大的坑。无 BOM 的中文脚本在 PowerShell 5.1 下必报 UnexpectedToken,存文件时务必带 BOM。
$Pid是 PowerShell 只读自动变量:它永远等于当前 PowerShell 进程的 PID,不能当普通变量或参数名用。脚本里函数参数改叫$TargetPid来规避(早前因此报过 VariableNotWritable)。- Start-Process 的 stdout/stderr 不能重定向到同一个文件:会报 InvalidOperationException,所以拆成了 dsh.log 和 dsh.err.log 两个文件。
- 改完 profile 要新开窗口:已经打开的 PowerShell 窗口不会自动加载新 Profile。
- 改过脚本记得重新存 BOM:有些编辑器编辑完会把 BOM 丢掉,中文解析错误就回来了。改完重跑上面那条转换命令最保险。
- 别占
~/.dsh目录:那是 dsh 项目自己的数据目录,这套启动器用的是独立的~/.dsh-launcher。
附录 A:主脚本 dsh.ps1(完整源码)
文件路径:C:\Users\<你的用户名>\.dsh-launcher\dsh.ps1
# dsh launcher - 管理 DeepSeek Harness (dsh) 的后台启动/停止
# 用法: dsh start | stop | status
$ErrorActionPreference = 'Stop'
$LauncherDir = Join-Path $HOME '.dsh-launcher'
$PidFile = Join-Path $LauncherDir 'dsh.pid'
$LogFile = Join-Path $LauncherDir 'dsh.log'
$ErrLogFile = Join-Path $LauncherDir 'dsh.err.log'
$Port = 3080
$WebUrl = "http://127.0.0.1:$Port"
# 通过端口反查占用进程的 PID(返回整数,无则 $null)
function Get-DshPidByPort {
$conn = Get-NetTCPConnection -LocalPort $Port -State Listen -ErrorAction SilentlyContinue |
Sort-Object -Property OwningProcess -Unique
if ($conn) { return $conn[0].OwningProcess }
return $null
}
# 判断某个 PID 是否为 dsh 进程(依据命令行含 "dsh")
function Test-IsDshProcess([int]$TargetPid) {
try {
$proc = Get-CimInstance Win32_Process -Filter "ProcessId = $TargetPid" -ErrorAction Stop
if (-not $proc) { return $false }
$name = $proc.Name
$cmdline = $proc.CommandLine
return ($name -eq 'node.exe' -and $cmdline -like '*dsh*')
} catch {
return $false
}
}
# 从 PID 文件读取记录的 PID(有效则返回整数,否则 $null)
function Get-RecordedPid {
if (-not (Test-Path $PidFile)) { return $null }
$raw = (Get-Content $PidFile -ErrorAction SilentlyContinue | Select-Object -First 1).Trim()
if ($raw -match '^\d+$') { return [int]$raw }
return $null
}
function Start-Dsh {
# 1. 已通过 PID 文件记录
$recorded = Get-RecordedPid
if ($recorded) {
if (Test-IsDshProcess $recorded) {
Write-Host "dsh 已在运行 (PID $recorded),访问 $WebUrl" -ForegroundColor Yellow
return
} else {
# 记录已失效,清理
Remove-Item $PidFile -Force -ErrorAction SilentlyContinue
}
}
# 2. 兜底:已通过端口占用判断
$portPid = Get-DshPidByPort
if ($portPid -and (Test-IsDshProcess $portPid)) {
Write-Host "dsh 已在运行 (PID $portPid),访问 $WebUrl" -ForegroundColor Yellow
return
}
# 3. 未运行,启动
Write-Host "正在启动 dsh ..." -ForegroundColor Cyan
$proc = Start-Process -FilePath 'npx.cmd' `
-ArgumentList '-y','@deepseek-ai/dsh','web' `
-WorkingDirectory $HOME `
-WindowStyle Hidden `
-RedirectStandardOutput $LogFile `
-RedirectStandardError $ErrLogFile `
-PassThru
# npx 启动的比较慢,稍等再看端口
$pidFound = $null
for ($i = 0; $i -lt 30; $i++) {
Start-Sleep -Seconds 1
$pidFound = Get-DshPidByPort
if ($pidFound -and (Test-IsDshProcess $pidFound)) { break }
}
if ($pidFound) {
$pidFound | Out-File -FilePath $PidFile -Encoding ascii
Write-Host "dsh 已启动 (PID $pidFound),访问 $WebUrl" -ForegroundColor Green
} else {
Write-Host "dsh 启动可能失败,请查看日志: $LogFile" -ForegroundColor Red
if (Test-Path $LogFile) { Get-Content $LogFile -Tail 20 }
}
}
function Stop-Dsh {
$recorded = Get-RecordedPid
$target = $null
$source = ''
if ($recorded -and (Test-IsDshProcess $recorded)) {
$target = $recorded
$source = 'PID 记录'
} else {
# 无有效记录,尝试按端口兜底
$portPid = Get-DshPidByPort
if ($portPid -and (Test-IsDshProcess $portPid)) {
$target = $portPid
$source = '端口反查'
}
}
if (-not $target) {
if ($recorded) {
Write-Host "PID 文件记录了 $recorded,但该进程已不存在或不是 dsh,未执行任何操作。" -ForegroundColor Yellow
} else {
Write-Host "未找到正在运行的 dsh 进程,无需停止。" -ForegroundColor Yellow
}
Remove-Item $PidFile -Force -ErrorAction SilentlyContinue
return
}
Write-Host "正在停止 dsh (通过${source}找到 PID $target) ..." -ForegroundColor Cyan
Stop-Process -Id $target -Force -ErrorAction SilentlyContinue
Remove-Item $PidFile -Force -ErrorAction SilentlyContinue
Write-Host "dsh 已停止。" -ForegroundColor Green
}
function Show-DshStatus {
$recorded = Get-RecordedPid
if ($recorded -and (Test-IsDshProcess $recorded)) {
Write-Host "dsh 正在运行。" -ForegroundColor Green
Write-Host " PID : $recorded (来自 PID 记录)"
Write-Host " 地址 : $WebUrl"
return
}
$portPid = Get-DshPidByPort
if ($portPid -and (Test-IsDshProcess $portPid)) {
Write-Host "dsh 正在运行。" -ForegroundColor Green
Write-Host " PID : $portPid (来自端口反查,PID 记录缺失/失效)"
Write-Host " 地址 : $WebUrl"
return
}
Write-Host "dsh 未在运行。" -ForegroundColor Yellow
if ($recorded) { Write-Host " (存在失效的 PID 记录: $recorded)" -ForegroundColor DarkYellow }
}
function Show-DshHelp {
Write-Host @"
dsh launcher - DeepSeek Harness 后台管理
用法:
dsh start 后台启动 dsh Web UI (${WebUrl})
dsh stop 停止 dsh(优先用 PID 记录,否则按端口反查)
dsh status 查看 dsh 运行状态
dsh 显示本帮助
辅助文件目录: ${LauncherDir}
"@
}
# 入口
switch ($args[0]) {
'start' { Start-Dsh }
'stop' { Stop-Dsh }
'status' { Show-DshStatus }
$null { Show-DshHelp }
default { Write-Host "未知命令: $($args[0])" -ForegroundColor Red; Show-DshHelp }
}
附录 B:Profile 注入片段
追加到 $PROFILE.CurrentUserAllHosts(即 C:\Users\<你的用户名>\Documents\WindowsPowerShell\profile.ps1)末尾:
# dsh launcher - DeepSeek Harness 后台管理
function dsh {
& (Join-Path $HOME '.dsh-launcher\dsh.ps1') @args
}
文件里如果已经有别的内容(比如 conda 初始化块),保留原样,只在末尾追加。
整套配下来五分钟,之后 dsh 就随开随用了。如果还遇到别的坑,欢迎评论区一起聊。

浙公网安备 33010602011771号